Solidity 基础(一)

11/12/2023 Blockchain

摘要

心不死则道不生,倘若穷途末路,那便势如破竹,愿我们全力以赴,不留遗憾,身不苦,则福禄不厚,心不苦,则智慧不开。

# 一:基础知识

以太坊使用 Keccak256 作为它的散列算法。

以太币是以太坊上的货币。以太坊的每次活动都需要消耗以太币作为费用,成功产生区块的矿工也会获得以太币作为奖励,以太币通过交易平台很容易兑换成法定货币。以太币采用十进制的计量体系,其最小的单位是 wei。

# 1.1 gas

由于以太币在交易所进行公开买卖,因此它的价格会上下波动。在支付费用时,如果直接使用以太币,那么,由于其价格不固定,同一个服务所花费的成本可能会忽高忽低。这样的话,人们会选择当以太币价格处于低谷时再去执行交易。对于一个平台来说,出现这种情况并不理想。gas(燃料) 的作用就是缓解这个问题,它是以太坊当前的内部货币。使用 gas 进行计价,用户能够预先确定一个交易的执行成本,这就是 gas成本。采取这种方法,当以太币价格暴涨时,gas价格 可以适当调低,当以太币价格暴跌时,gas价格可以适当提高。例如,使用智能合约里面的函数去修改一个字符,这个交易将会消耗一定的gas,由于gas的使用成本已经事先确定,因此用户就可以有规律地执行智能合约了。

# 1.2 区块链和以太坊架构

区块链是一种包含多个组件的体系结构,区块链独特的地方在于这些组件的功能和相互作用。重要的组件包括:EVM(Ethereum VirtuaI Machine 以太坊虚拟机)、矿工、区块、交易、共识算法、账户、智能合约、挖矿、以太币和gas。

一个区块链网络是由大量的节点构成的,其中一部分是属于矿工的挖矿节点,另一部分节点不挖矿但会帮助执行智能合约和交易。这些节点统称为EVM。网络上的各个节点之间互相连接,节点之间通过 P2P 协议进行通信,默认情况下使用 30303 端口。

每个节点都维护着一个账本的实例(副本),包含链上的全部区块。由于网络上存在大量矿工节点,为了避免节点之间的区块数据存在差异,这些节点会持续同步区块,确保账本数据一致。

智能合约也运行在 EVM 上。智能合约通过编写个性化的业务功能扩展以太坊的能力。智能合约执行的时候,是作为交易的一部分按照前面提到的挖矿流程进行的。

在网络上有账户的用户可以发送信息来完成账户之间的以太币交易,或者发送消息来调用合约中的一个函数。对以太坊来说,这两种方式实质上都是交易。在交易确认和更改账户余额时,账户所有人必须用私钥对交易进行数字签名,这样才能确定发送者的身份。

以太坊有个创世区块的概念,它就是第一个区块。这个区块是在链初次发起时自动创建的。可以认为,整个链条是由创世区块(通过genesis.json文件来生成)作为第一个区块而开始启动的。

在以太坊中,交易是存储在区块里面的,交易在执行时需要消耗一定数量的 gas,由于每个区块都有一个gas上限,待执行的交易消耗的 gas 总数不能超过上限,这样可以避免将所有交易存储在一个区块里面。当达到 gas 上限时,其他交易就不能再写入这个区块,此时,节点开始挖矿。

一个交易在产生散列值之后,就被存储到区块中,接着,挖矿程序会选取两个交易的散列值进行再次散列,再产生一个新的散列值。很显然,区块里面的所有交易最终会产生一个唯一的散列值,它就是 MerkIe根散列值,保存在区块头上。如果区块里面的任何一个交易发生了改变,这个交易的散列值也会随之变化,最终会导致根交易散列值发生变化。当区块的散列值发生变化后,由于子区块保存了父区块的散列值,因此,它的子区块的散列值也需要随之变化,这样会引发连锁反应,使得整个区块链都要变化才行,因此,区块链的不可篡改才成为可能。

# 1.3 以太坊节点

节点就是计算机,它们之间通过p2p协议互相连接,组成了以太坊网络。

以太坊有两种类型的节点:

  • EVM
  • 挖矿节点

需要注意的是,这种分类只是为了对概念进行细分,在大多数场景中,并没有专门的 EVM,相反的是,所有的矿工节点都承担了 EVM 的职能。

EVM 是以太坊网络的运行环境,其主要职能就是提供执行智能合约代码的环境。EVM能够访问智能合约内部和外部的账户,以及自己存储的数据,但它只能访问当前交易内的信息,不能访问全部账本

矿工的奖励有两种:第一种是向区块链写入区块的奖励,第二种是能够获得区块内的交易所支付的 gas 费用。一般情况下,区块链上存在很多矿工,它们之间互相竞争,然而,最终只有一个矿工能够获胜并向账本写人区块,其他矿工则无权写入。判断矿工获胜是通过破解一道难题的方式。在挖矿时,每个矿工需要解决的难题是一样的,矿工们只能通过自己的机器计算能力去破解。第一个解决了这个难题的矿工会把含有交易的区块写入自己本地的账本,然后发送区块和 Nonce值 给其他矿工进行确认,其他矿工接收区块并验证该答案是否正确,通过后,区块就会被矿工写入到自己的账本中。在这个过程中,赢得挑战的矿工也会得到5个以太币的回报。每个节点都维护了一套以太坊账本的副本,矿工的职责就是通过数据同步使得本地账本处于最新的状态,最终各个矿工之间的账本实现了一致性。

矿工或者挖矿节点有三个重要的功能:

  • 挖矿或打包交易产生新的区块并写入到以太坊账本;
  • 向其他矿工发送最新的区块,并通知他们接收;
  • 接收其他矿工的区块,并更新本地的账本。

为了获得挖矿奖励,矿工需要添加自己的币基(coinbase)交易(区块中第一笔交易是挖矿的回报交易),接着,产生区块头并执行下列步骤:

  1. 矿工抓取两个交易的散列值,进行再次散列并产生一个新的散列值,直到对所有交易完成散列后,将得到唯一的散列值,这就是 根交易散列值Merkle根交易散列值,它将被添加到区块头。
  2. 矿工也需要确定上一个区块的散列值,因为它是当前区块的父区块,父区块的散列值要保存在当前区块头中。
  3. 矿工计算交易的 state 和 receipts 根散列值,然后写入区块头。
  4. Nonce 和时间戳记录到区块头。
  5. 产生整个区块(包括区块头和区块体)的散列值。
  6. 挖矿流程开始,矿工持续变换 Nonce 值,直到发现该散列值能够解决难题为止,需要记住的是,对网络上的矿工而言,执行过程是一样的。
  7. 很显然,某一矿工最终能够找到这个难题的答案,它会将结果发送给网络上的其他矿工。其他矿工会先确认该答案是否正确,如果是正确的,将开始验证每个交易,然后接受该区块,并添加到他们的本地账本中。

这里的 state 和 receipts 指的是另外两个 Merkle树,以太坊有三棵 Merkle树,即:交易 Merkle 树,state Merkle 树和收据 Merkle 树。

上面的整个挖矿流程,因为矿工提供了经过不断计算而获得了解题答案的证明,所以,被称之为 工作量证明(pow)。另外,还有其他一些算法,如:权益证明(PoS)权威证明(PoA),不过以太坊并没有用到这些算法。

区块头和它的组成部分如下图所示:

# 1.4 以太坊账户

以太坊有两种类型的账户:外部账户合约账户。每个账户默认有一个余额属性,可以查看该账户以太币的当前余额。

  • 外部账户,以太坊上的用户所拥有的账户。在以太坊中,账户不能使用名称来调用。当在以太坊上创建一个外部账户时,会产生一对公钥和私钥。私钥需要你保存在安全的地方,而公钥就是你对账户持有所有权的证明。外部账户能够持有以太币,但是不能执行任何代码。它能够与其他外部账户执行交易,也可以借助于智能合约中的函数执行交易。
  • 合约账户,合约账户与外部账户很相似,可以从公开的地址来识别它们。合约账户没有私钥,但可以像外部账户一样持有以太币,不同的是,合约账户可以包含代码,它的代码是由函数和状态变量组成的。

# 1.5 交易

下面是以太坊上支持的交易类型:

  1. 从一个账户向另外一个账户发送以太币:这个账户可能是外部账户或者合约账户。下面的场景都可能发生:
    • 在交易中,一个外部账户向另外一个外部账户发送以太币
    • 在交易中,一个外部账户向一个合约账户发送以太币
    • 在交易中,一个合约账户向另外一个合约账户发送以太币
    • 在交易中,一个合约账户向一个外部账户发送以太币
  2. 智能合约部署:外部账户在EVM上部署合约是通过交易的方式实现的。

使用或借助合约内的函数:如果需要执行合约内的函数去改变一个状态,就需要一个交易,如果执行函数没有改变任何一个状态,就不需要交易。

与交易有关的一些重要属性:

  • From:说明了这个账户是交易的发起方,发送 gas 或以太币。From 账户可以是外部账户或合约账户。
  • To:指的是接收以太币或其他收益的账户,它可以是外部账户或合约账户。如果是部署合约的交易,则 To 字段为空。
  • Value:指的是账户之间转移的以太印数量。
  • Input:指的是合约编译后被部署在 EVM 上的字节码。input 也用于保存有关智能合约函数带参调用的信息。下面json展示在典型的以太坊交易中使用智能合约函数的地方,注意 Input 字段中包含了带有参数的函数调用。
{
	blockHash:'0xba93a91df520c7565e8... ...',
	blockNumber: 70,
	from: '0xa57de277ede9c... ...',
	gas: 90000,
	gasPrice: BigNumber {s: 1, e: 10, c: [ 18000000000 ] },
	hash: '0x65b86462e6aa89d5f946... ...',
	input: '0xc8aaea40000000000000000... ...',
	nonce: 1,
	to: '0x6b90c690... ...',
	transactionIndex: 0,
	value: BigNumber { s: 1, e: 0, c: [0]},
	v: '0x42',
	r: '0xd4f5ad... ...',
	s: '0x7f89cda2... ...'
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  • BlockHash:该交易所属的区块的散列值;
  • BlockNumber:交易所属的区块序号;
  • Gas:交易的发送方支付的 gas 数量;
  • GasPrice:发送方支付的 gas 价格,以 wei 为单位。总的 gas 消耗 = gas 数量 * gas 价格
  • Hash:交易的散列值;
  • Nonce:交易的编号,它由发送方在当前交易之前产生;
  • TransactionIndex:区块中当前交易的流水号;
  • Value:用 wei 计算的传递的以太的数量;
  • v、r 和 s:指的是数字签名和交易的签名。

外部账户之间转移以太币也可以使用下面的代码来实现,它基于的是 web3 JavaScript 框架。

web.eth.sendTransaction({from: web.eth.accounts[0], to: "0x6b90c690... ...", value: web.toWei(2, 'ether') })
1

# 1.6 区块

区块是交易的容器,它由多个交易组成。因为受区块的 gas 上限和区块大小限制,每个区块包含的交易数量并不相同。区块连接在一起形成了区块链,除了第一个区块(创世区块)没有父区块外,其他区块都有一个父区块,父区块的散列值存储在区块头上。

{
	difficulty: BigNumber { s: 1, e: 5, c: [ 135070 ]},
	extraData: '0xd783010702846... ...',
	gasLimit: 4011042861,
	gasUsed: 43406,
	hash: '0xba93a91df520c7565e... ...',
	logsBloom: '0x000000000000000000000... ...',
	miner: '0xa57de277ede9c1521f51... ...',
	mixHash: '0x4e80de770c329aebbc... ...',
	nonce: '0x655dee191333922c',
	number: 70,
	parentHash: '0x27d3dbc34614f885... ...',
	receiptsRoot: '0x5dff465dd85c4ad... ...', 
	sha3Uncles: '0x1dcc4de8dec75d7aa... ...',
	size: 742,
	stateRoot: '0xb15363a8958d218eff... ...',
	timestamp: 1511421241,
	totalDifficulty: BigNumber { s: 1, e: 6, c: [ 9302609 ] },
	transactions: [ '0x6b65b86462e6aa... ...' ],
	transactionsRoot: '0x5aceca068d1a7ac... ...',
	uncles: [] 
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

区块有很多属性,为了便于掌握关键内容,下面只介绍一些重要的部分:

  • Difficulty:矿工为了挖到这个区块而需要面对的计算难度;
  • GasLimit:区块允许的 gas 总量上限。它决定了区块中能包含多少个交易;
  • GasUsed:区块中的交易实际消耗的 gas 数量;
  • Hash:这个区块的散列值;
  • Nonce:一个数字,它是解决难题的答案;
  • Miner:矿工的账户,可以用 coinbase 或 etherbase 的地址;
  • Number:该区块在区块链上的序号;
  • ParentHash:父区块的散列值;
  • ReceiptsRoot、stateRoot 和 TransactionsRoot:merkle 树;
  • Transactions:区块中的交易组成的一个数组;
  • TotalDifficulty:区块链的整体难度。

# 1.7 端到端的交易

例子:Sam 打算发送一个数字资产(如:美元)给 Mark。首先,Sam 新建了一个交易,里面包括了 from、to、value 等字段数据,然后发送到以太网络上,该交易并没有立即写人到账本中,而是暂存到交易池中。

挖矿节点新建了一个区块,然后按照 gas 上限标准,从交易池中提取交易(Sam 的交易也将被提取),并添加到区块中,网络上的全体矿工都在执行相同的任务。

接下来,矿工们开始争先恐后地去计算难题,在一段时间(或几秒)后,获胜者(第一个解决难题的人)会发出通知,声称他找到了答案,赢得了比赛,需要向区块链写入区块,与此同时,获胜者将答案添加到区块上并发送给其他矿工。其他矿工收到通知后,首先验证这个答案,一旦认定该答案确实有效,就立即停止自己的计算,接收这个包含了 Sam 的交易的区块,然后添加到他们的本地账本中。这样下来,就在链上产生了一个新的区块,它将一直跨越时间和空间而永久的存在下去。在这期间,双方的账户余额都会得到更新,最后,区块被分发复制到网络上的全部节点。这个过程如下:

# 1.8 合约

合约是经过双方或多方同意,约定立即执行或在将来执行一项交易的法律文件。因为合约是法律文件,所以它具有强制性和可执行性。合约应用的场景很多,例如:一个人和保险公司签订合同购买健康险,一个人从另外一个人手里购买一块土地,一个公司出售股权给另外一家公司。

智能合约:是按照用户的需求编写的代码,并部署和运行在以太坊虚拟机上。智能合约是数字化的,它在代码中固化了账户之间交易的规则。智能合约有利于通过原子化交易来实现数字资产的转移,也可以用于存储重要数据,这些数据可以用来记录信息、事件、关系、余额、以及现实世界中的合同中需要约定的信息。智能合约类似于面向对象的 class 类,因此,一个合约可以调用另外一个合约,就像我们可以在类对象之间进行互相调用和实例化。也可以这样认为,智能合约就是由函数构成的小程序。你可以新建一个合约,借助合约里面的函数去查看区块链上的数据,以及按照一些规则去更新数据。

编写智能合约的工具有很多种,如:Visual Studio。其中,最简单和最快速的开发方法时使用基于浏览器的开发工具,例如,Remix,具体可以查看 Remix 教程

# 二:安装以太坊和Solidity

# 2.1 主网

以太坊主网是全球性的公开网络,人人都可以使用,通过账户就能进入网络。对于任何人来说,创建账户、部署解决方案和合约都是免费的。主网的使用费用以 gas 来计量。它的代号是 homestead(以前叫做 Frontier)

  • 测试网络:目的是帮助人们快速适应和使用以太坊环境,是从主网精确复制而来。在测试网络上,部署和使用合约,都不会发生真实的费用,这是因为测试用的以太币都是任意产生的,只能在测试网上使用。例如下面几种测试网络:
    • Ropsten:第一个使用 PoW 共识算法产生区块的测试网络。以前被叫做:Morden。无偿使用它构建和测试合约。在 Geth 中加入 testnet 参数就可以进入测试网络。Ropsten 是目前为止最受欢迎的测试网络。
    • Rinkeby:另外一个以太坊测试网络,它使用的是 PoA 共识机制。PoW 和 PoA 在矿工间建立共识时的机制是不同的。PoW 在维护不可篡改性和去中心化方面更强壮,然而它的缺陷是不能有效地控制矿工。PoA 具备了 PoW 的优点,也能对矿工具有一定的控制能力。
    • Kovan:只能在少部分用户之间使用。
  • 私有网络:在用户自有的网络上建立和运行的,控制权掌握在一个组织手里。出于测试的考虑,人们并不希望将解决方案、合约和场景放到公共网络上,这样就需要建立一个开发、测试和生产的环境,因此,用户应该搭建一个私有网络,这样就能够全盘进行掌控。
  • 联盟网络:也是一个私有网络,只是有一点区别,联盟网络的节点是由不同组织所管理的。实际上,联盟链上也没有哪个组织能够单独地控制网络和数据,而是由全体组织和组织里面具有查看和修改权限的人共同进行控制的。联盟链可以通过互联网或 VPN 网络接入。

# 2.2 Geth

目前存在多种语言编写的以太坊客节点和客户端工具,包括:Go、C++、Python、javaScript、Java 和 Ruby 等。例如使用 Go 语言,被称为 Geth,可以作为客户端连接到公共的和测试的网络上,它也可以在私有网络上运行挖矿和 EVM(交易节点)。

Geth 是使用 Go 语言编写的命令行工具,可以使用它在私有网络上创建节点和矿工。Geth 可以在 Windows、Linux 和 Mac 环境下安装。安装过程可以查看 在 windows 下安装 geth

Geth 是基于 JSON RPC 的协议。它定义了采用 JSON 格式的代码远程调用规范。Geth 可以使用下面三种 JSON RPC 协议进行连接:

  • 内部进程通信(IPC,lnter Process Communication):内部通讯,通常用于一台电垴内;
  • 远程程序调用(RPC,Remote Procedure Calls):跨计算机的通讯。通常使用 TCP 和 HTTP 协议;
  • WS,Web sockets:使用 sockets 连接 Geth。

Geth 可以直接使用 Geth(不带选项)直接连接公链。Homestead 就是当前公链的名称。它的**NetworkidChainID 是 1**。

在不同的网络上,其 Chain ID 是不同的。其中:

  • 1 是主网公链;
  • 2 是 Morden 网络(仅对部分人开放);
  • 3 是 Ropsten 网络;
  • 4 是 Rinkeby 网络;
  • 大于4 的是私有网络。

另外,可以使用 geth --testnet 连接到 Ropsten 网,使用 geth --rinkebyRinkeby 网,Geth 也可以与 chain ID 配合使用。

# 2.3 搭建私有网络

Geth 安装好后,可以先在本地进行配置,这时不需要连接到互联网。每个网络都有一个创世区块,它是整个网络的第一个区块,没有父区块。创世区块由 genesis.json 文件产生。具体创建过程可以查看 搭建私有网络

# 2.4 ganache-cli

在以太坊上,交易写入账本分为两个阶段:

  • 创建交易,然后将其放入交易池中;
  • 定期从交易池中获取交易,然后开始挖矿。挖矿意味着将这些交易写入以太坊数据库或账本的。

在以太坊上进行解决方案和智能合约的开发和测试,将十分浪费时间。ganache-cli(以前叫作:TestRPC)就是为了缓解这个问题,ganache-cli 包含了以太坊的交易处理流程和挖矿功能,但挖矿时不需要竞赛,交易产生后就会立即写入账本。对于开发者来说,使用 ganache-cli 可以作为以太坊节点,不需要挖矿交易就可以写入账本。具体安装使用过程查看 ganache-cli

# 2.5 Solidity 编译器

SoIidity 是一种编写智能合约的语言。编写完 Solidity 代码后,需要使用 SoIidity 编译器去编译,它将生成字节码和其他输出物,它们在部署智能合约时会用到。在以前,Solidity 是 Geth 安装包的一部分,现在已经脱离了 Geth 的安装过程,需要自行安装。Solidity 编译器被称之为 solc,可以使用 npm 命令进行安装,具体安装过程查看 Solidity 编译器

# 2.6 Web3 JavaScript 库

web3 库是开源的 JavaScript 库,可以连接到本地或远程的以太坊节点,它使用的是 IPC 或 RPC 协议。web3 库是面向客户端的,可以和 Web 页面一起使用,以便向以太坊节点发起查询请求和交易提交请求。可以使用节点管理工具来进行安装,就像 SoIidity 编译器的安装一样。具体安装过程查看 Web3 JavaScript

# 2.7 Mist 钱包

以太坊上面运行着以太币,因此,需要一个钱包来发送和接收以太币。Mist 就是这样一个接收和发送以太币的钱包,同时它也有利于用户在以太网络上(包括公有和私有网络)进行交易。在 Mist 上,用户可以创建账户、发送和接收以太币、部署和调用智能合约。具体安装过程查看 Mist 钱包

# 2.8 MetaMask

MetaMask 是 Chrome、Edge 浏览器的一个轻量级插件,它能够与以太坊网络进行交互。它也是一个发送和接收以太币的钱包。因为 MetaMask 运行在浏览器里,因此,区块数据没有办法下载到本地,只能是存储在远程服务器上,用户通过浏览器去访问它。具体安装过程查看 MetaMask

# 三:Solidity 介绍

# 3.1 以太坊虚拟机

Solidity 是针对以太坊虚拟机(EVM)的编程语言。以太坊区块链通过编写和执行称为智能合约的代码来帮助扩展其功能。

EVM 执行作为智能合约的一部分的代码。智能合约是用 Solidity 语言写的,然而,EVM 并不理解 Solidity 的高级结构。EVM 可以理解的是称为 字节码 的低级指令。需要编译器将 Solidity 代码转换为 EVM 可理解的字节码。Solidity 附带的编译器称为 Solidity 编译器 或 solc,它负责完成这种转换。

# 3.2 Solidity 和 Solidity 文件

Solidity 是一种非常接近 JavaScript 的编程语言。在 SoIidity 中可以找到 JavaScript 和 C 之间的相似之处。SoIidity 是一种静态类型、区分大小写的面向对象编程(OOP)语言。虽然它是面向对象的,但支持有限的面向对象特征。这意味着在编译时,应该定义并且已知变量的数据类型。应该按照面向对象编程的方式来定义函数和变量。SoIidity 语句的终结符是分号 ;。扩展名为 .sol

Solidity 文件由以下四个高级结构组成:

  1. 预编译指令:通常是任何 Solidity 文件中的第一行代码;
    • pragma:指定当前 Solidity 文件编译器版本的指令。如 pragma Solidity ^0.4.19,注:区分大小写,其中4是主版本号,19是此版本号;
      • ^:指主版本中的最新版本。如 ^0.4.0 指版本号为4的最新版,可能是 0.4.19;
      • ^ 字符除了提供的主版本号之外不针对任何其他主版本;
      • Solidity 文件只能用主版本号大于 4 的编译器进行编译,不能在其他主版本号的编译器上编译。
  2. 注释
  3. 导入import 关键词可以导入其他 Solidity 文件,以便在当前的 Solidity 文件和代码中访问其他代码。如 import CommonLibrary.sol
    • 文件名可以是完全显式或隐式路径;
    • / 用于将目录从其他目录和文件中分离出来;
    • . 用于引用当前目录;
    • .. 用于引用父目录。
  4. 合约 / 库 / 接口,可以定义全局或顶级的合约、库和接口。如下:
pragma solidity 0.4.19;

contract firstContract {

}

contract secondContract {

}

library stringLibrary {

}

library mathLibrary {

}

interface IBank {

}

interface IAccount {

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

最好使用精确的编译器版本而不是使用 ^ 来指定编译 Solidity 代码。当在 pragma 指令中使用 ^ 时,如果编译器的新版本有改动,可能会弃用你的代码。例如,throw 语句已被弃用,并建议使用如 assertrequirerevert

# 3.3 合约的结构

合约由以下多个结构组成:

  • 状态变量,可运行时更改。由矿工永久存储在区块链 / 以太坊账本中。内存是静态分配的,并且在合约生命周期内不能更改。
    • 限定符:
      • internal:默认情况下,如果没有指定任何内容,则状态变量具有 internal 限定符。此变量只能在当前的合约函数和任何继承它们的合约中使用。这些变量不能被外部访问修改,但是,可以查看它们。
        • int internal StateVariable;
      • private:私有状态变量只能在声明它们的合约中使用。即使在派生合约中也不能使用它们。
        • int private privateStateVariable;
      • public:可以直接访问该状态变量,Solidity 编译器为每个公共状态变量生成一个 getter 函数。
        • int public stateIntVariable;
      • constant:变量不可变。变量声明时必须赋初值。实际上,编译器会在所有代码中将变量的引用替换为指定的值。
        • bool constant hasIncome = true;
    • 数据类型:
      • bool
      • unit / int
      • bytes
      • address
      • mapping
      • enum
      • struct
      • bytes / String
  • 结构,复合数据类型,由多个不同数据类型的变量组成。与合约非常相似,但是,它们不包含任何代码,只包含变量。关键字 struct
struct myStruct {
	string name;
	unit age;
	bool isMarried;
	unit[] bankAccountsNumbers;
}
1
2
3
4
5
6
  • 修改器,执行目标函数之前执行的函数。关键字 modifier
    • 修改器中 _(下划线) 表示执行目标函数;
    • payable 是一种由 Solidity 提供的开箱即用的修改器,当应用于任何函数时允许该函数接受以太币。
modifier onlyBy() {
	if (msg.sender == personIdentifier) {
		_;
	}
}
1
2
3
4
5
  • 事件,从合约中触发,Solidity 中的事件主要用于通过 EVM 的日志工具向调用应用程序通知合约的当前状态。通过轮询合约中特定状态的更改,合约可以通过事件通知。合约中声明的事件在全局域有效,并且被合约中的函数所调用。使用 event 关键字声明一个事件,后跟一个标识符和参数列表并以分号结尾。参数中的值可用于记录信息或执行条件逻辑。事件信息及其值作为交易的一部分存储在区块内。如:event ageRead(address, int);
  • 枚举,关键字 enum
    • 枚举中的常量值可以显式地转换为整数。每个常量值对应一个整数值,第一个值为0,每个连续项的值加1;
    • 至少需要一个成员
// 声明
enum gender {male, female}
// 声明并赋值枚举变量
gender _gender = gender.male;
1
2
3
4
  • 函数,当调用或触发合约中的某个函数时,会导致创建一个交易。函数机制是为了从状态变量读取值和向状态变量写人值。可以以匿名方式命名函数。Solidity 提供了命名函数,在合约中只能有一个称为 fallback 函数的未命名函数。
    • 限定符:
      • public:可以在内部和外部调用;
      • internal:默认情况下,如果没有指定,则状态变量具有 internal 限定符。这意味着此函数只能用于当前合约以及任何从其继承的合约。这些函数不能从外部访问,它们不是合约接口的一部分;
      • private:私有函数只能在声明它们的合约中使用,即使在派生合约中也不能使用它们。它们不是合约接口的一部分;
      • external:函数可以直接从外部但不是内部访问。这些函数是合约接囗的一部分;
      • constant:这些函数不具有修改区块链状态的能力。可以读取状态变量并返回给调用者,将常量函数看作可以读取和返回当前状态变量值的函数。不能修改任何变量、触发事件、创建另一个合约、调用其他可以改变状态的数等;
      • view:常量函数的别名;
      • pure:pure 函数既不能读取也不能写入,即它们不能访问状态变量。使用此限定符声明的函数应确保它们不会访问当前状态和交易变量;
      • payable:使用 payable 关键字声明的函数能够接受来自调用者的以太币。如果发送者没有提供以太币,则调用将会失败。如果一个函数被标记为payabLe,该函数只能接受以太币。
// 声明函数 getAge
// 函数附加修改器 onlyBy()
function getAge (address _personIdentifier) onlyBy() payable external returns (unit) {

}
1
2
3
4
5

# 3.4 数据类型

Solidity 数据类型可以大致分为以下两种类型:

  • 值类型:将数据(值)直接保存在内存中。大小不超过 32 字节内存。
    • 如果将值类型变量赋给另一个变量,或者将值类型变量作为参数传送给函数,则 EVM 会创建一个新变量实例并将原始值类型的值复制到目标变量中。这被称为值传递。更改原始或目标变量中的值不会影响另一个变量中的值。这两个变量将保持其独立的值,并且它们可以在其他变量不知道的情况下更改值。
    • Solidity 提供以下值类型:
      • bool:true 或 false;
      • unit:无符号整数,只能保存 0 和正值;
      • int:保存负值和正值的有符号整数;
      • address:以太坊环境中的账户地;
      • byte:表示固定大小的字节数组(byte1 到 bytes32);
      • enum:可以保存预定义的常量值的枚举。
例子

数据类型为无符号整数(uint)的变量声明 13 作为其数据(值)。变量 a 具有由 EVM 分配的存储空间 0x123,并且该位置具有存储的值 13。访问这个变量将直接得到值 13

  • 引用类型:与值类型不同,引用类型不直接将其值存储在变量本身中。它们存储的不是值,而是值存储位置的地址。该变量保存了指向另一个实际存储数据的内存位置的指针。引用类型可以占用大于32字节的内存。
    • Solidity 提供以下引用类型:
      • 数组:固定大小或动态大小的数组。;
      • 结构:用户定义的结构;
      • 字符串:字符序列。在 Solidity 中,字符串最终被存储为字节;
      • 映射:与存储键值对的其他语言中的散列表或字典相似。
例子

声明了一个数据类型为 uint 的大小为 6 的数组变量。Solidity 中的数组是从 0 开始计数的,所以此数组可以包含 7 个元素。变量 a 由 EVM 分配存储空间 0x123,该位置保存了指针值 0x456。该指针指向存储数组数据的实际内存位置。访问该变量时,EVM 将引用该指针的值并显示数组索引中的值,如下:

当引用类型变量被赋给另一个变量时,或当引用类型变量作为参数传送给函数时,EVM 会创建一个新变量实例并将指针从原始变量复制到目标变量中。这被称为 引用传递。这两个变量都指向相同的地址位置。改变原始或目标变量中的值也会改变其他变量的值。这两个变量将共享相同的值,并且一个变量的变化反映在另一个变量中。

# 3.5 存储和内存数据位置

在合约中声明和使用的每个变量都有一个数据位置。EVM 提供以下四种用于存储变量的数据结构:

  • 存储:可以被合约内所有函数访问的全局内存变量。是以太坊将其存储在环境中每个节点上的永久存储。
  • 内存:合约中的每个函数都可以访问的本地内存。它是生命周期的短暂的内存,当函数执行完成后会被销毁。
  • 调用数据:存储所有传入的函数执行数据,包括函数参数。这是一个不可修改的内存位置。
  • 堆栈:EVM 维护用于加载变量和使用以太坊指令集的变量和中间值的堆栈。这是EVM的工作集内存。在 EVM 中,堆栈的深度为 1024 层,任何超过此数量的深度都会引发异常。

变量的数据位置取决于以下两个因素:

  • 变量声明的位置
  • 变量的数据类型

基于上述两个因素,有规则规定了如何管理和决定变量的数据位置。数据位置也会影响赋值运算符的工作方式。赋值和数据位置的管理是由规则来解释的。

  • 规则1:状态变量始终存储在存储数据位置;
  • 规则2:声明为函数参数的变量始终存储在内存数据位置;
  • 规则3:在默认情况下,在函数中声明的变量存储在内存数据位置。有以下几个注意点:
    • 函数中的值类型变量的存储位置是内存,而引用类型变量的缺省位置是存储。(在函数内声明的引用类型变量默认保存在存储中。但是,它可以被覆盖;
    • 通过覆盖默认位置,引用类型变量可以位于内存数据位置。引用的类型是数组、结构体和字符串;
    • 在函数中声明的引用类型不会被覆盖,应该始终指向一个状态变量;
    • 在函数中声明的值类型变量不能被覆盖,也不能存储在存储位置;
    • 映射总是在存储位置声明,这意味着不能在函数内声明它们。映射不能被声明为内存类型。但是,函数中的映射可以引用声明为状态变量的映射。
  • 规则4:调用者提供的函数参数始终存储在调用数据位置中;
  • 规则5:状态变量,通过另一个状态变量赋值,会创建一个新副本;
pragma solidity 0.4.19;
contract Demo1 {
	uint stateVar1 = 20;
	uint stateVar2 = 40;
	
	function getUInt() returns (uint) {
		stateVar1 = stateVar2;
		stateVar2 = 50;
		return stateVar1; // return 40
		// 说明每个变量保持其自己的独立值
	}
}

contract Demo2 {
	uint[2] stateArray1 = [uint(1), 2];
	unit[2] stateArray2 = [uint(3), 4];
	
	function getUInt() returns (unit) {
		stateArray1 = stateArray2;
        stateArray2[1] = 5;
        return stateArray1[1]; // return 4
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  • 规则6:将内存变量的值赋给存储变量时总是会创建一个新副本;
pragma solidity 0.4.19;
contract Demo1 {
	uint[2] stateArray;
    
	function getUInt() returns (uint) {
		uint[2] memory localArray = [uint(1), 2];
		stateArray = localArray;
		localArray[1] = 10;
		return stateArray[1]; // return 2
	}
}

contract Demo2 {
	uint stateVar = 20;
	
	function getUInt() returns (uint) {
		uint localVar = 40;
		stateVar = localVar;
		localVar = 50;
		return stateVar; // returns 40
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  • 规则7:将状态变量的值赋给内存变量时始终创建一个新副本 ;
pragma solidity 0.4.19;
contract Demo1 {
	uint stateVar = 20;
    
	function getUInt() returns (uint) {
		uint localVar = 40;
		localVar = stateVar;
		stateVar = 50;
		return localVar; // return 20
	}
}

contract Demo2 {
	uint[2] stateArray = [uint(1), 2];
	
	function getUInt() returns (uint) {
		uint[2] memory localArray = stateArray;
		stateArray[1] = 5;
		return localArray[1]; // returns 2
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  • 规则8:将内存变量赋给内存变量不会创建副本;但是,确实为值类型创建了一个新副本。
pragma solidity 0.4.19;
contract Demo1 {

	function getUInt() returns (uint) {
		uint localVar1 = 40;
		uint localVar2 = 80;
		localVar1 = localVar2;
		localVar2 = 100;
		return localVar1; // return 80
	}
}

contract Demo2 {
	uint stateVar = 20;
	
	function getUInt() returns (uint) {
		uint[] memory someVar = new uint[](1);
		someVar[0] = 23;
		uint[] memory otherVar = someVar;
		someVar[0] = 45;
		return (otherVar[0]); // returns 45
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 3.6 字面量

Solidity 为变量的赋值提供了字面量,如下字面量例子:

  • 整数字:1、10、1 000、-1 和 -100
  • 字符串字面量可以用单引号或双引号:"Ritesh"
  • 地址:0xca35b7d915458ef540ade6068dfe2f44e8fa733c
  • 十六进制以前缀 hex 作为关键字:hex "1A2B3F"
  • 十进制:4.50.2

# 3.7 整型

Solidity 提供两种类型的整数:

  • 有符号的整数:带符号的整数可以同时具有负值和正值;
  • 无符号整数:无符号整数只能保持正值和零。除正值和零值以外,它们也可以保持负值。

声明时自动初始化,默认0

对于每种类型,Solidity 都有多种类型的整数。Solidity 提供了 uint8 类型来表示8位无符号整数,并且以8的倍数表示,直到达到256。总之,可以声明32个不同的具有8的倍数的无符号整数,例如 uint8、uint16、uint24、uint256 位。同样,有符号整数的数据类型也是相同的,如 int8、int16,直到 int256

# 3.8 布尔型

值为 truefalse,Solidity 中的布尔不能转换为整数值类型,任何赋值给其他的布尔变量都会创建一个新副本。默认值为 false

# 3.9 字节

字节是指8位有符号整数。Solidity 具有多种字节类型。提供的数据类型范围为 bytes1 ~ bytes32(含),以根据需要表示不同的字节长度。这些被称为 固定大小的字节数组,且为值类型。bytes1 代表1个字节,bytes2 代表2个字节。字节的默认值是 0x00,并用此值初始化。byte 类型是 bytes1 的别名。









 


// 十六进制
bytes1 aa = 0x65;
// 十进制
bytes1 bb = 10;
// 十进制负整数值
bytes1 ee = -100;
// 字符值
bytes1 dd = 'a';
// 256 不适合放入单个字节,需要更大的字节数组
bytes2 cc = 256;
1
2
3
4
5
6
7
8
9
10

# 3.10 数组

Solidity 中的数组可以是固定或动态的。

  1. 固定数组:声明了预定大小的数组。无法使用 new 关键字进行初始化,只能以内联方式初始化。
int[5] a;

int[5] b = [int(10), 20, 30, 40, 50];

int[5] c;
c = [int(10), 2, 3, 4, 5];
1
2
3
4
5
6
  1. 动态数组:声明时没有预定大小的数组,但大小在运行时确定的。可以内联初始化或使用 new 运算符初始化。
int[] a;

int[] b = [int(10), 20, 30, 40, 50];

int[] c = new int[](5);

int[] d;
d = new int[](5);
1
2
3
4
5
6
7
8
  1. 特殊数组
    • 字节数组:动态数组,可以容纳任意数量的字节。与 byte[] 不同,byte[] 数组每个元素占用32个字节,而字节数组紧紧地将所有字节保存在一起。
    • 字符串数组:不能被索引或压栈,不具有 length 属性,要对字符串变量执行这些操作,先将其转换为字节,然后在操作其转换回字符串。
// -----------字节数组-----------
// 字节可以声明为具有初始长度大小的状态变量
bytes a = new bytes(0);
// 可以直接赋值
a = "Ritesh";
// 如果数据位于存储位置,可以将值压栈
a.push(byte(10));
// 提供 读/写 长度属性
return a.length;
a.length = 4;

// -----------字符串数组-----------
String name = 'Ritesh';
// 转换字节
Bytes byteName = bytes(name);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

数组基本属性:(并非每种类型都支持)

  • index:除了字符串类型外,所有类型的数组都支持用于读取单个数组元素的 index 属性。仅动态数组,固定数组和字节类型支持用于写入单个数组元素的index属性。字符串和固定大小的字节数组不支持写入;
  • push:仅动态数组支持此属性;
  • length:除了字符串类型外,此属性由读取透视图中的所有数组支持。只有动态数组和字节支持修改长度属性。

# 3.11 数组的结构

结构有助于将不同数据类型的多组变量转换为单一类型。结构不包含任何用于执行的编程逻辑或代码;它仅包含变量声明。结构是引用类型,在 Solidity 中被视为复杂类型。

pragma solidity 0.4.19;

contract Demo {
	
	struct myStruct {
		string name;
		uint myAge;
		bool isMarried;
		uint[] bankAccountsNumbers;
	}

	function getAge() returns (uint) {
		myStruct memory localStructure = myStruct("Modi", 20, false, new uint[](2));
		myStruct memory pointerlocalStructure = localStructure;
		localStructure.myAge = 30;
		return pointerlocalStructure.myAge;	// returns 30
	}
	
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 3.12 枚举

枚举是包含一个预定义的常量值列表的值类型。通过值传递,每个副本都维护自己的值。不能在函数内声明枚举,并在合约的全局域命名空间内声明。预定义的常量是连续赋值的,从零开始增加整数值。

web3 和 去中心化应用(DApp)不理解合约中声明的枚举。它们将得到一个对应于枚举常量的整数值。

pragma solidity 0.4.19;

contract Enums {
	// 将整数 0、1、2、3、4 赋值给他们
	enum status {created, approved, provisioned, rejected, deleted}

	status myStatus = status.provisioned;

	// 返回整数值
	function returnEnum() returns (status) {
		status stat = status.created;
		return stat;
	}
	
	function returnEnumInt() returns (uint) {
		status stat = status.approved;
		return uint(stat);
	}
	
	// 枚举实例维护自己的本地副本并不与其他实例共享
	function passByValue() returns (uint) {
		status stat = myStatus;
		myStatus = status.rejected;
		return uint(myStatus);
	}
	
	// 一个整数被赋值为一个枚举实例的值
	function assignInteger() returns (uint) {
		status stat = myStatus;
		myStatus = status(2);
		return uint(myStatus);
	}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

# 3.13 地址

地址是 20 字节的数据类型。可以保存合约账户地址以及外部拥有的账户地址。地址是一种值类型,它被赋值给另一个变量时会创建一个新副本。

地址具有 balance 属性,该属性返回账户可用的以太币数量,并具有一些用于账户间交易以太币和调用合约函数的功能。

提供以下两个函数来交易以太币:

  • transfer
  • send

当向一个账户发送以太币时,更应该选择 transfer 函数而不是 send 函数。send 函数返回一个布尔值,具体取决于以太币发送是否成功执行,而 transfer 函数引发异常并将以太币返还给调用者。

提供用于调用合约函数的函数:

  • Call
  • DelegateCall
  • callcode

# 3.14 映射

映射类似于其他语言中的散列表或字典。存储键值对,并允许根据提供的键来检索值。

使用 mapping 关键字声明映射,后跟由 => 表示法分隔的键和值的数据类型。映射具有与任何其他数据类型一样的标识符,并且它们可用于访问映射。

Solidity 不允许迭代映射

pragma solidity 0.4.19;

contract GeneralMapping {
	// uint数据类型用于存储键,address数据类型用于存储值
	mapping (uint => address) Names;
	
	uint counter;
	
	function addtoMapping(address addressDetails) returns (uint) {
		counter = counter + 1;
		// 在映射中存储值  Names[counter] = <<some value>>
		Names[counter] = addressDetails;
		return counter;	// returns false
	}
	
	function getMappingMember(uint id) returns (address) {
		return Names[id];
	}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

虽然映射不支持迭代,但有一些方法可以解决这个限制。在以太坊的 gas 使用方面,通常应该避免 迭代 和 循环 这类昂贵的操作。

pragma solidity 0.4.19;

contract MappingLooping {
	mapping (uint => address) Names;
	uint counter;
	
	function addtoMapping(address addressDetails) returns (uint) {
		counter = counter + 1;
		Names[counter] = addressDetails;
		return counter;
	}
	
	// 用数组存储,达到伪循环
	function getMappingMember(uint id) returns (address[]) {
		address[] memory localBytes = new address[](counter);
		for(uint i = 1; i <= counter; i++) {
			localBytes[i - 1] = Names[i];
		}
		return localBytes;
	}
	
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

只能将映射声明为 storage 类型的状态变量。不能在函数内将映射声明为内存映射。但是,如果映射引用状态变量中声明的映射,则可以在函数中声明映射。

pragma solidity 0.4.19;

contract MappinginMemory {
	mapping(uint => address) Names;
	uint counter;
	
	function addtoMapping(address addressDetails) returns (uint) {
		counter = counter + 1;
		// 合法,因为 localNames 映射引用 Names 状态变量
		mapping (uint => address) localNames = Names;
		localNames[counter] = addressDetails;
		return counter;
	}
	
	function getMappingMember(uint id) returns (address) {
		return Names[id];
	}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

嵌套映射

pragma solidity 0.4.19;

contract DemoInnerMapping {

    mapping (uint => mapping(address => string)) accountDetails;
    uint counter;

    function addtoMapping(address ads, bytes name) returns (uint) {
        string memory names = string(name);
        counter = counter + 1;
        accountDetails[counter][ads] = names;

        return counter; 
    }

    function getMappingMember(address addressDetails) returns (bytes) {
    	return bytes( accountDetails[counter][addressDetails]);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 四:参考文献

  • 《Solidity编程 构建以太坊和区块链智能合约的初学者指南 - 瑞提什·莫迪》
最后更新: 12/12/2023, 5:32:22 PM