连载序言:
从本文开始,我将连载 Solidity 文档(以太坊官方 Solidity 开发手册)的中文版。这份文档的英文原文可以在以太坊官网的最下方 Solidity 链接中找到。官方的英文版本文档中有中译版链接,即是本连载内容的出处。这个连载将按照英文文档的先后顺序进行。
Solidity 是以太坊官方的智能合约开发高级语言。这份中文译本是由 Hiblock 社区组织贡献的,官方 Github:https://github.com/etherchina/solidity-doc-cn。
我本人于3月初加入此项目,目前作为管理员、贡献者和校订人利用业余时间参与日常工作;截至到4月底,翻译工作已完成大半。有兴趣的朋友请直接在以太坊官网的链接中查看最新中文版本状态,或者关注上述中文译本的 Github repository。
出于单独阅读的需要,我在连载中会删除原文里的rst控制标签、外部链接以及文内链接。
本文作为 Solidity 文档的开篇,介绍了几乎所有以太坊智能合约相关的基本概念,对我们学习以太坊智能合约开发非常重要,是初学者不应该忽视的章节。缺乏对基本概念的理解,在以后的学习和编码中会举步维艰,请初学的朋友务必重视。
智能合约概述
简单的智能合约
让我们先看一下最基本的例子。现在就算你都不理解也不要紧,后面我们会有更深入的讲解。
储存
pragma solidity ^0.4.0;
contract SimpleStorage {
uint storedData;
function set(uint x) public {
storedData = x;
}
function get() public constant returns (uint) {
return storedData;
}
}
第一行就是告诉大家源代码是用 Solidity 版本 0.4.0 写的,并且使用 0.4.0 以上版本运行也没问题(最高到 0.5.0,但是不包含 0.5.0)。这是为了确保合约不会在新的编译器版本中突然行为异常。一般来说,被称为 pragma
的关键字,就是用来告知编译器该如何处理源代码的指令。
Solidity 中合约的含义就是一组代码(它的 函数 )和数据(它的 状态 ),它们位于以太坊区块链的一个特定地址上。 代码行 uint storedData;
声明了一个类型为 uint
(256位无符号整数)的状态变量,叫做 storedData
。 你可以认为它是数据库里的一个位置,可以通过调用管理数据库代码的函数进行查询和变更。对于以太坊来说,上述的合约就是拥有合约(owning contract)。在这种情况下,函数 set
和 get
可以用来变更或取出变量的值。
要访问一个状态变量,并不需要像 this.
这样的前缀,虽然这是其他语言常见的做法。
该合约能完成的事情并不多(由于以太坊构建的基础架构的原因):它能允许任何人在合约中存储一个单独的数字,并且这个数字可以被世界上任何人访问,且没有可行的方法阻止你发布这个数字。当然,任何人都可以再次调用 set
,传入不同的值,覆盖你的数字,但是这个数字仍会被存储在区块链的历史记录中。稍后,我们会看到怎样施加访问限制,以确保只有你才能改变这个数字。
注意:所有的标识符(合约名称,函数名称和变量名称)都只能使用 ASCII 字符集。UTF-8 编码的数据可以用字符串变量的形式存储。
警告:小心使用 Unicode 文本,因为有些字符虽然长得相像(甚至一样),但其字符编码值是不同的,其编码后的字符数组也会不一样。
子货币(Subcurrency)例子
下面的合约实现了一个最简单的加密货币。这里,币(Coin)确实可以无中生有地产生,但是只有创建合约的人才能做到(实现一个不同的发行计划也不难)。而且,任何人都可以给其他人转币,不需要注册用户名和密码 —— 所需要的只是以太坊密钥对。
pragma solidity ^0.4.21;
contract Coin {
// 关键字“public”让这些变量可以从外部读取
address public minter;
mapping (address => uint) public balances;
// 轻客户端可以通过事件针对变化作出高效的反应
event Sent(address from, address to, uint amount);
// 这是构造函数,只有当合约创建时运行
function Coin() public {
minter = msg.sender;
}
function mint(address receiver, uint amount) public {
if (msg.sender != minter) return;
balances[receiver] += amount;
}
function send(address receiver, uint amount) public {
if (balances[msg.sender] < amount) return;
balances[msg.sender] -= amount;
balances[receiver] += amount;
emit Sent(msg.sender, receiver, amount);
}
}
这个合约引入了一些新的概念,让我们逐一解读。
address public minter;
这一行声明了一个可以被公开访问的 address
类型的状态变量。 address
类型是一个 160 位的值,且不允许任何算数操作。这种类型适合存储合约地址或外部人员的密钥对。关键字 public
会自动生成一个函数,允许你在这个合约之外访问这个状态变量的当前值。如果没有这个关键字,其他的合约没有办法访问这个变量。由编译器生成的函数的代码大致如下所示:
function minter() returns (address) { return minter; }
当然,增加一个和上面完全一样的函数是行不通的,因为我们会有同名的一个函数和一个变量,这里,主要是希望你能明白——编译器已经帮你实现了。
下一行, mapping (address => uint) public balances;
也创建一个公共状态变量,但它是一个更复杂的数据类型。该类型将 address 映射为无符号整数。Mapping 可以看作是一个哈希表,它会执行虚拟初始化,以使所有可能存在的键都映射到一个字节表示为全零的值。 但是,这种类比并不太恰当,因为我们既不能获得映射的所有键的列表,也不能获得所有值的列表。 因此,要么记住你添加到 mapping 中的数据(使用列表或更高级的数据类型会更好),要么在不需要键列表或值列表的上下文中使用它,就如本例。 而由 public
关键字创建的 getter 函数 则是更复杂一些的情况, 它大致如下所示:
function balances(address _account) public view returns (uint) {
return balances[_account];
}
正如你所看到的,你可以通过该函数轻松地查询到账户的余额。
event Sent(address from, address to, uint amount);
这行声明了一个所谓的“事件(event)”,它会在 send
函数的最后一行被发出。 用户界面(当然也包括服务器应用程序)可以监听区块链上正在产生的事件,而不用花费太多成本。一旦它被发出,监听该事件的 listener 都将收到通知。而所有的事件都包含了 from
, to
和 amount
三个参数,可方便追踪交易。 为了监听这个事件,你可以使用如下代码:
Coin.Sent().watch({}, '', function(error, result) {
if (!error) {
console.log("Coin transfer: " + result.args.amount +
" coins were sent from " + result.args.from +
" to " + result.args.to + ".");
console.log("Balances now:\n" +
"Sender: " + Coin.balances.call(result.args.from) +
"Receiver: " + Coin.balances.call(result.args.to));
}
})
这里请注意自动生成的 balances
函数是如何从用户界面调用的。
特殊函数 Coin
是在创建合约期间执行的构造函数,不能在事后调用。它永久存储创建合约的人的地址: msg
(以及 tx
和 block
) 是一个神奇的全局变量,其中包含一些允许访问区块链的属性。 msg.sender
始终是当前(外部)函数调用的来源地址。
最后,真正被用户或其他合约所调用的,用以完成本合约功能的方法是 mint
和 send
。如果 mint
被合约创建者外的其他人调用则什么也不会发生。 另一方面, send
函数可被任何人用来向其他人发送币(当然,前提是发送者拥有这些币)。记住,如果你使用合约发送币到一个地址,当你在区块链浏览器上查看该地址时是看不到任何相关信息的。因为,实际上你发送币和更改余额的信息仅仅存储在特定合约的数据存储区中。通过使用事件,你可以非常简单地为你的新币创建一个“区块链浏览器”来追踪交易和余额。
区块链基础
对于程序员来说,区块链这个概念并不难理解,这是因为大多数难懂的东西 (例如挖矿、哈希、椭圆曲线密码学、点对点(P2P)网络等等)都只是用于提供一些特定的功能和承诺。你只需接受这些既有的特性,不必关心底层技术,比如,难道你必须知道亚马逊的 AWS 的内部原理,你才能使用它吗?
交易
区块链是全球共享的交易数据库,这意味着每个人都可加入网络来阅读数据库中的记录。如果你想改变数据库中的某些东西,你必须创建一个被所有其他人所接受的交易。交易一词意味着你想做的(假设你想要同时更改两个值),要么一点没做,要么全部完成。此外,当你的交易被应用到数据库时,其他交易不能修改数据库。
举个例子,设想一张表,列出电子货币中所有账户的余额。如果请求从一个账户转移到另一个账户,数据库的交易特性确保了如果从一个账户扣除金额,它总被添加到另一个账户。如果由于某些原因,无法添加金额到目标账户时,源账户也不会发生任何变化。
此外,交易总是由发送人(创建者)签名。
这样,就可以非常简单地为数据库的特定修改增加访问保护机制。 在电子货币的例子中,一个简单的检查可以确保只有持有账户密钥的人才能从中转账。
区块
在比特币中,要解决的一个主要难题,被称为“双花攻击 (double-spend attack)”:如果网络存在两笔交易,都想花光同一个账户的钱时会发生什么情况?一个所谓的冲突?
简单的回答是你不必在乎这个问题。网络会为你自动选择一个交易顺序,并把交易打包到所谓的“区块”中,然后它们将在所有参与的节点中执行和分发。如果两笔交易互相矛盾,那么最终被确定为后发生的交易将被拒绝,不会被包含到区块中。
这些块按时间的先后顺序组成一个线性序列,这正是“区块链”这个词的来源。区块以一定的时间间隔添加到链上 —— 对于以太坊,这间隔大约是17秒。
作为“顺序选择机制”(也就是所谓的“挖矿”)的一部分,可能有时会发生区块被回滚的情况,但仅在链的“末端”。末端增加的块越多,其发生回滚的概率越低。所以,你的交易被回滚甚至从区块链中抹除的情况是可能发生的,但等待的时间越长,这种情况发生的概率就越低。
以太坊虚拟机
概述
以太坊虚拟机(或称 EVM)是智能合约的运行环境。它不仅是由沙盒所封装的,而且是完全隔离的,也就是说在 EVM 中运行代码是无法访问网络、文件系统和其他进程的。甚至智能合约之间的访问也是受限的。
账户
以太坊中有两类账户(它们共用同一个地址空间): 外部账户 由公钥-私钥对(也就是人)控制; 合约账户 由和账户一起存储的代码控制。
外部账户的地址是由公钥决定的,而合约账户的地址是在创建该合约时确定的(这个地址通过合约创建者的地址和从该地址发出过的交易数量计算得到的,也就是所谓的“nonce”)。
无论帐户是否存储代码,这两类账户对 EVM 来讲都是一样的。
每个账户都有一个键值对形式的持久化储存区域。其中 key 和 value 的长度都是256位,我们称之为 存储 。
此外,每个账户都有一个以太币余额( balance )(单位是“Wei”),余额会因为发送包含以太币的交易而改变。
交易
交易可以看作是从一个帐户发送到另一个帐户的消息(这里的账户,可能是相同的或特殊的零帐户,请参阅下文)。它能包含一个二进制数据(它的 payload)和以太币。
如果目标账户含有代码,此代码会被执行,并以 payload 作为输入参数。
如果目标账户是零账户(账户地址为 0
),此交易将创建一个 新合约 。如前文所述,合约的地址不是零地址,而是通过合约创建者的地址和从该地址发出过的交易数量计算得到的(所谓的“nonce”)。这个用来创建合约的交易的 payload 会被转换为 EVM 字节码并执行。执行的输出将作为合约代码被永久存储。这意味着,为创建一个合约,你不需要向合约发送真正的合约代码,而是发送能够产生真正代码的代码。
注意:在合约创建的过程中,合约的代码是不存在的。所以,在合约构造完成之前,你不应该从构造调用合约的任何函数。
Gas
一经创建,每笔交易都收取一定数量的 gas ,目的是限制执行交易所需要的工作量和为交易支付手续费。EVM 执行交易时,gas 将按特定规则逐渐耗尽。
gas price 是交易发送者设置的一个值,发送者账户需要预付的手续费为 gas_price * gas
。如果交易执行后还有剩余, gas 会原路返还。
无论执行到什么位置,一旦 gas 被耗尽(即降为负值),将会触发一个 out-of-gas 异常。当前调用帧(call frame)所做的所有状态修改都将被撤销。
译者注:调用帧(call frame),指的是下文讲到的EVM的运行栈(stack)中当前操作所需要的若干元素。
存储,内存和栈
每个账户都有一块持久化的内存区域,称为 存储(storage)。存储是一个键值对存储区,它将若干 256 位的字(键)映射到若干 256 位的字(值)。(译者注,字是 EVM 数据处理的最小单位。)在合约中枚举存储是不可能的;读取存储的开销很高,修改存储的开销则更高。合约只能读写存储区内属于自己的部分。
第二类内存区域称为 内存(memory),合约的每一次消息调用之前会获得一块被清理干净的内存实例。 内存是线性的,可按字节寻址,但读取数据的长度被限制为 256 位,而写入的长度可以是 8 位或 256 位。当访问(无论是读还是写)之前从未访问过的内存字(word)时(无论是偏移到该字内的任何位置),内存都将按字进行扩展(每个字是256位)。做这种扩展会消耗一定量的 gas。 随着内存使用量的增长,其费用也会增高(以平方级别)。
EVM 不是基于寄存器的,而是基于栈的,所有的计算都在一个被称为 栈(stack) 的区域执行。栈里最多可以有 1024 个元素,每个元素长度是一个字(256 位)。对栈的访问只限于其顶端,限制方式为:允许拷贝最顶端的 16 个元素中的一个到栈顶,或者是交换栈顶元素和其下 16 个元素中的一个。所有其他操作都只能取最顶的两个(或一个,或更多,取决于具体的操作)元素,运算后,把结果压入栈顶。当然可以把栈上的元素放到存储或内存中。但是无法只访问栈上任意深度的某个元素,除非先从栈顶移除其他元素。
指令集
EVM 的指令集的设计原则是尽量精简,以最大限度地避免可能导致共识问题的错误实现。所有指令都是针对"256位的字(word)"这个基本的数据类型来进行操作的。通常的算术运算、位运算、逻辑运算和比较操作都可以支持。同时也支持有条件和无条件跳转。此外,合约还可以访问当前区块的相关属性,比如它的编号和时间戳。
消息调用
合约可以通过消息调用的方式来调用其它合约或者发送以太币到非合约账户。消息调用和交易非常类似,它们都有一个源、一个目标、payload 数据、以太币、gas 和返回数据。事实上每个交易都由一个顶级(top-level)消息调用组成,这个消息调用又可创建更多的消息调用。
合约可以决定在其内部的消息调用中,对于剩余的 gas ,应发送和保留多少。如果在内部消息调用时发生了 out-of-gas 异常(或其他任何异常),这将由一个被压入栈顶的错误值所指明。此时,只有与该内部消息调用一起发送的 gas 会被消耗掉。在 Solidity 中,发起调用的合约默认会触发一个手工的异常,以便异常可以从调用栈里“冒泡出来”。
如前文所述,被调用的合约(可以和调用者是同一个合约)会获得一块刚刚清空过的内存,并可以访问调用的 payload——由被称为 calldata 的独立区域所提供的数据。调用执行结束后,返回数据将被存放在调用方预先分配好的一块内存中。
调用深度被 限制 为 1024 ,因此对于更加复杂的操作,我们应使用循环而不是递归。
委托调用/代码调用和库
有一种特殊类型的消息调用,被称为 委托调用(delegatecall) 。它和一般的消息调用的区别在于,目标地址的代码将在发起调用的合约的上下文中执行,并且 msg.sender
和 msg.value
不变。
这意味着一个合约可以在运行时从另外一个地址动态加载代码。存储、当前地址和余额都指向发起调用的合约,只有代码是从被调用地址获取的。
这使得 Solidity 可以实现”库(library)“的特性:可复用的代码库可以放在一个合约的存储上,如用来实现复杂的数据结构的库。
日志
有一种特殊的可索引的数据结构,其存储的数据可以一直映射到区块级别。这个特性被称为 日志(logs) ,Solidity用它来实现 事件(events) 。合约在被创建之后就无法访问日志数据了,但是这些数据可以从区块链外部高效地访问。因为一部分日志数据被存储在布隆过滤器(Bloom filter)中,我们可以通过高效且加密安全的方式搜索日志,所以那些没有下载整个区块链的网络节点(“轻客户端”)也可以找到这些日志。
创建
合约甚至可以通过一个特殊的指令来创建其他合约(不是简单的调用零地址)。创建合约的调用 create calls 和普通消息调用的唯一区别在于,payload 数据会被执行,执行的结果被存储为合约代码,调用者/创建者会在栈上获得新合约的地址。
自毁
将合约代码从区块链上移除的唯一方式是合约在其合约地址上执行自毁(selfdestruct
)操作 。合约账户上剩余的以太币会发送给指定的目标,然后其存储和代码会从状态中被移除。(译者注,这里的“从状态中被移除”所说的“状态”,指以太坊的“World State”。这是一个抽象概念,可以理解为以太坊中所有账户的数据状态的一个快照,是通过一种改进的前缀树数据结构来实现的。)
警告:即使一个合约的代码中没有显式地调用
selfdestruct
,它依然可以通过delegatecall
或callcode
执行自毁操作。
注意:旧合约的删减可能会,也可能不会被以太坊的各种客户端程序实现。另外,归档节点可选择无限期保留合约存储和代码。
注意:目前, 外部账户 不能从状态中移除。
网友评论