入门说明
在把智能协议传上以太坊之后,它就变得不可更改, 这种永固性意味着你的代码永远不能被调整或更新。你编译的程序会一直,永久的,不可更改的,存在以太坊上。
非常重要的是,部署在以太坊上的 DApp,并不能保证它真正做到去中心,我们需要阅读并理解它的源代码,才能防止其中没有被部署者恶意植入后门;作为开发人员,如何做到既要给自己留下修复 bug 的余地,又要尽量地放权给使用者,以便让他们放心你,从而愿意把数据放在你的 DApp 中,这确实需要个微妙的平衡。
文件结构
1 | pragma solidity ^0.4.0; |
- 版本要高于0.4才可以编译
- 表示高于0.5的版本则不可编译,第三位的版本号但可以变,留出来用做bug可以修复(如0.4.1的编译器有bug,可在0.4.2修复,现有合约不用改代码)。
引用源文件
全局引入:
1 | import “filename”; |
自定义命名空间引入:
1 | import * as symbolName from “filename” |
分别定义引入:
1 | import {symbol1 as alias, symbol2} from “filename” |
非es6兼容的简写语法:
1 | import “filename” as symbolName |
类型
值类型:
- 布尔(Booleans)
- 整型(Integer)
- 地址(Address)
- 定长字节数组(fixed byte arrays)
- 有理数和整型(Rational and Integer Literals,String literals)
- 枚举类型(Enums)
- 函数(Function Types)
引用类型
- 不定长字节数组(bytes)
- 字符串(string)
- 数组(Array)
- 结构体(Struts)
数据位置
复杂类型,如数组(arrays)和数据结构(struct)在Solidity中有一个额外的属性,数据的存储位置。可选为memory和storage。
memory存储位置同我们普通程序的内存一致。即分配,即使用,越过作用域即不可被访问,等待被回收。而在区块链上,由于底层实现了图灵完备,故而会有非常多的状态需要永久记录下来,那么我们就要使用storage类型了,一旦使用这个类型,数据将永远存在。
基于程序的上下文,大多数时候这样的选择是默认的,我们可以通过指定关键字storage和memory修改它。默认的函数参数,包括返回的参数,他们是memory。默认的局部变量是storage的。默认的状态变量(合约声明的公有变量)是storage。
另外还有第三个存储位置calldata。它存储的是函数参数,是只读的,不会永久存储的一个数据位置。外部函数的参数(不包括返回参数)被强制指定为calldata。效果与memory差不多。
- storage转换为storage只是修改了它的指针。
- 将一个memory类型的变量赋值给一个状态变量时,实际是将内存变量拷贝到存储中。
- memory赋值给局部变量:局部变量虽然是一个storage的,但它仅仅是一个storage类型的指针,所以直接赋值会报错,不能将memory赋值给局部变量.
- 将storage转为memory,实际是将数据从storage拷贝到memory中。
- 将一个memory的引用类型赋值给另一个memory的引用,不会创建拷贝(即:memory之间是引用传递)。
- 对于值类型,总是会进行拷贝。
1 | pragma solidity ^0.4.0; |
强制的数据位置:
- 外部函数(External function)的参数(不包括返回参数)强制为:calldata
- 状态变量(State variables)强制为: storage
默认数据位置:
- 函数参数(包括返回参数):memory
- 所有其它的局部变量:storage
布尔
bool可能的取值为常量值true和false
整型
int/uint:
- 变长的有符号或无符号整型。变量支持的步长以8递增,支持从uint8到uint256,以及int8到int256。uint和int默认代表的是uint256和int256
字面量:
- 整数字面量,由包含0-9的数字序列组成,默认被解释成十进制。在Solidity中不支持八进制,前导0会被默认忽略,如0100,会被认为是100。
- 小数由
.
组成,在他的左边或右边至少要包含一个数字。如1.,.1,1.3均是有效的小数。
字面量本身支持任意精度,也就是可以不会运算溢出,或除法截断。但当它被转换成对应的非字面量类型,如整数或小数。或者将他们与非字面量进行运算,则不能保证精度了。总之就是,字面量怎么都计算都行,但一旦转为对应的变量后,再计算就不保证精度了。
1 | pragma solidity ^0.4.0; |
十六进制字面量,以关键字hex打头,后面紧跟用单或双引号包裹的字符串。如hex”001122ff”。在内部会被表示为二进制流。由于一个字节是8位,所以一个hex是由两个[0-9a-z]字符组成的,不是成双的字符串是会报错的。十六进制的字面量与字符串可以进行同样的类似操作:
1 | pragma solidity ^0.4.0; |
地址
address:以太坊地址的长度,大小20个字节,160位,所以可以用一个uint160编码。地址是所有合约的基础,所有的合约都会继承地址对象(注意:从0.5.0开始,合约不再继承自地址类型,但仍然可以显式转换为地址),也可以随时将一个地址串,得到对应的代码进行调用。当然地址代表一个普通帐户时,就没有这么多丰富的功能了。
地址类型的成员:
- 属性:balance
- 函数:send(),call(),delegatecall(),callcode()。
十六进制的字符串,凡是能通过地址合法性检查(address checksum test)2,就会被认为是地址,如0xdCad3a6d3569DF655070DEd06cb7A1b2Ccd1D3AF。需要注意的是39到41位长的没有通过地址合法性检查的,会提示一个警告,但会被视为普通的有理数字面量。
如果只是想得到当前合约的余额,可以这样写:
1 | pragma solidity ^0.4.0; |
transfer()
transfer()用来发送以太币(以wei为单位)
1 | address x = 0x123; |
如果x是合约地址,合约的回退函数(fallback 函数)会随transfer调用一起执行(这个是EVM特性),如果因gas耗光或其他原因失败,转移交易会还原并且合约会抛异常停止。
send()
用来向某个地址发送货币(货币单位是wei)。
1 | pragma solidity ^0.4.0; |
这个合约实现的是充值。this.send(msg.value)意指向合约自身发送msg.value量的以太币。msg.value是合约调用方附带的以太币。
send()方法执行时有一些风险:
- send与transfer对应,但更底层。如果执行失败,transfer不会因异常停止,而send会返回false。
- 调用递归深度不能超1024。
- 如果gas不够,执行会失败。
- 所以使用这个方法要检查成功与否。或为保险起见,货币操作时要使用一些最佳实践。如果执行失败,将会回撤所有交易,所以务必留意返回结果。
为了同一些不支持ABI协议的进行直接交互(一般的web3.js,soldity都是支持的)。可以使用call()函数,用来向另一个合约发送原始数据。参数支持任何类型任意数量。每个参数会按规则(规则是按ABI)打包成32字节并一一拼接到一起。call()方法支持ABI协议定义的函数选择器。如果第一个参数恰好4个字节,在这种情况下,会被认为根据ABI协议定义的函数器指定的函数签名。所以如果你只是想发送消息体,需要避免第一个参数是4个字节。call方法返回一个bool值,以表明执行成功还是失败。正常结束返回true,异常终止返回false。我们无法解析返回结果,因为这样我们得事前知道返回的数据的编码和数据大小(这里的潜在假设是不知道对方使用的协议格式,所以也不会知道返回的结果如何解析,有点祼协议测试的感觉)。
同样也可以使用delegatecall(),它与call方法的区别在于,仅仅是代码会执行,而其它方面,如(存储,余额等)都是用的当前的合约的数据。delegatecall()方法的目的是用来执行另一个合约中的工具库。所以开发者需要保证两个合约中的存储变量能兼容,来保证delegatecall()能顺利执行。
上面的这三个方法call(),delegatecall(),callcode()都是底层的消息传递调用,最好仅在万不得已才进行使用,因为他们破坏了Solidity的类型安全。上述的函数都是底层的函数,使用时要异常小心。当调用一个未知的,可能是恶意的合约时,当你把控制权交给它,它可能回调回你的合约,所以要准备好在调用返回时,应对你的状态变量可能被恶意篡改的情况。
定长字节数组
bytes1, … ,bytes32,允许值以步长1递增。byte默认表示byte1。成员变量length。
枚举类型(enum)
枚举类型是在Solidity中的一种用户自定义类型。他可以显示的与整数进行转换,但不能进行隐式转换。显示的转换会在运行时检查数值范围,如果不匹配,将会引起异常。枚举类型应至少有一名成员。
1 | pragma solidity ^0.4.0; |
函数
函数的分类:
- 内部函数(internal):因为不能在当前合约的上下文环境以外的地方执行,内部函数只能在当前合约内被使用。如在当前的代码块内,包括内部库函数,和继承的函数中。
- 外部函数(External):外部函数由地址和函数方法签名两部分组成。可作为外部函数调用的参数,或者由外部函数调用返回。
函数的定义:
1 | function (<parameter types>) {internal(默认)|external} [constant] [payable] [returns (<return types>)] |
若不写类型,默认的函数类型是internal的。如果函数没有返回结果,则必须省略returns关键字。
1 | pragma solidity ^0.4.0; |
函数的internal与external:调用一个函数f()时,我们可以直接调用f(),或者使用this.f()。但两者有一个区别。前者是通过internal的方式在调用,而后者是通过external的方式在调用。请注意,这里关于this的使用与大多数语言相背。下面通过一个例子来了解他们的不同:
1 | pragma solidity ^0.4.5; |
不定长字节数组
- bytes:动态长度的字节数组。
字符串
字符串(string)字面量是指由单引号,或双引号引起来的字符串。字符串并不像C语言,包含结束符,“foo”这个字符串大小仅为三个字节。字符串的长度类型可以是变长的,特殊之处在于,可以隐式的转换为byte1,…byte32。
1 | pragma solidity ^0.4.0; |
数组
创建一个数组
1 | pragma solidity ^0.4.0; |
Memory数组
对于memory的变长数组,不支持修改length属性,来调整数组大小。memory的变长数组虽然可以通过参数灵活指定大小,但一旦创建,大小不可调整。
push方法
变长的storage数组和bytes(不包括string)有一个push()方法。可以将一个新元素附加到数组最后端,返回值为当前长度uint。
1 | pragma solidity ^0.4.0; |
memory的数组不可修改,不支持push方法。
多维数组
uint[2][3]在大多数语言中,表示的是两行三列的数组,而Solidity切好相反,它表示的是三行两列的数组。但是访问数组的方法与其他语言一致。
1 | pragma solidity ^0.4.4; |
固定的字节数组和可变的字节数组
- bytes和string是一种特殊的数组。bytes类似于byte[],但在外部函数作为参数调用中,会进行压缩打包,更省空间,所以应该尽量使用bytes而不是byte[].
- bytes0~bytes32 表示创建固定字节大小的数组,不可修改。
- string是特殊的可变字节数组。可以转换为bytes以通过length获得它的字节长度。也可以通过索引来修改对应的字节内容,通过push方法来增加字节内容。
- 由于bytes与string,可以自由转换,你可以将字符串s通过bytes(s)转为一个bytes。但需要注意的是通过这种方式访问到的是UTF-8编码的码流,并不是独立的一个个字符。比如中文编码是多字节,变长的,所以你访问到的很有可能只是其中的一个代码点。
1 | pragma solidity ^0.4.0; |
结构体
- 不能声明一个struct同时将这个struct作为这个struct的一个成员。这个限制是基于结构体的大小必须是有限的。
- 在函数中,将一个struct赋值给一个局部变量(默认是storage类型),实际是拷贝的引用,所以修改局部变量值时,会影响到原变量。
- 通常情况下不会考虑使用 uint 变种,因为无论如何定义 uint的大小,Solidity 都会为它保留256位的存储空间。例如,使用 uint8 而不是uint(uint256)不会节省任何 gas。
- 而如果一个 struct 中有多个 uint,则尽可能使用较小的 uint, Solidity 会将这些 uint 打包在一起,从而占用较少的存储空间。
- 结构体是不对外可见的(当前只支持internal),所以只可以在当前合约,或合约的子类中使用。包含自定义结构体的函数均需要声明为internal或private的。
1 | pragma solidity ^0.4.0; |
字典
定义方式为mapping(_KeyType => _KeyValue)。键的类型允许除映射外的所有类型,如数组,合约,枚举,结构体。值的类型无限制。
在映射表中,我们并不存储键的数据,仅仅存储它的keccak256哈希值,用来查找值时使用。因此,映射并没有长度,键集合(或列表),值集合(或列表)这样的概念。
映射类型,仅能用来定义状态变量,或者是在内部函数中作为storage类型的引用。引用是指你可以声明一个,如var storage mappVal的用于存储状态变量的引用的对象,但你没办法使用非状态变量来初始化这个引用。
可以通过将映射标记为public,来让Solidity创建一个访问器。要想访问这样的映射,需要提供一个键值做为参数。如果映射的值类型也是映射,使用访问器访问时,要提供这个映射值所对应的键,不断重复这个过程。下面来看一个例子:
1 | contract MappingExample{ |
由于调试时,你不一定方便知道自己的发起地址,所以把这个函数,略微调整了一下,以在调用时,返回调用者的地址。编译上述合同后,可以先调用update(),执行成功后,查看调用信息,能看到你更新的地址,这样再查一下这个地址的在映射里存的值。
如果你想通过合约进行上述调用。
1 | pragma solidity ^0.4.0; |
映射并未提供迭代输出的方法,可以自行实现一个数据结构。
运算符delete
delete运算符,用于将某个变量重置为初始值。对于整数,运算符的效果等同于a = 0。而对于定长数组,则是把数组中的每个元素置为初始值,变长数组则是将长度置为0。对于结构体,也是类似,是将所有的成员均重置为初始值。delete对于映射类型几乎无影响,因为键可能是任意的,且往往不可知。所以如果你删除一个结构体,它会递归删除所有非mapping的成员。当然,你是可以单独删除映射里的某个键,以及这个键映射的某个值。
需要强调的是delete a的行为更像赋值,为a赋予一个新对象。我们来看看下文的示例:
1 | pragma solidity ^0.4.0; |
通过上面的代码,我们可以看出,对于值类型,是值传递,删除x不会影响到data,同样的删除data也不会影响到x。因为他们都存了一份原值的拷贝。而对于复杂类型略有不同,复杂类型在赋值时使用的是引用传递。删除会影响所有相关变量。比如上述代码中,删除dataArray同样会影响到y。由于delete的行为更像是赋值操作,所以不能在上述代码中执行delete y,因为不能对一个storage的引用赋值
类型推断
为了方便,并不总是需要明确指定一个变量的类型,编译器会通过第一个向这个对象赋予的值的类型来进行推断1。
1 | uint24 x = 0x123; |
函数的参数,包括返回参数,不可以使用var这种不指定类型的方式。
需要特别注意的是,由于类型推断是根据第一个变量进行的赋值。所以代码for (var i = 0; i < 2000; i++) {}将是一个无限循环,因为一个uint8的i的将小于2000。
1 | pragma solidity ^0.4.4; |
单位
货币单位
一个字面量的数字,可以使用后缀wei,finney,szabo或ether来在不同面额中转换。不含任何后缀的默认单位是wei。如2 ether == 2000 finney的结果是true。
1 | pragma solidity ^0.4.0; |
时间单位(Time Units)
seconds,minutes,hours,days,weeks,years均可做为后缀,并进行相互转换,默认是seconds为单位。
变量 now 将返回当前的unix时间戳(自1970年1月1日以来经过的秒数)。Unix时间传统用一个32位的整数进行存储,这会导致“2038年”问题,当这个32位的unix时间戳不够用,产生溢出,使用这个时间的遗留系统就麻烦了。所以,如果我们想让我们的 DApp 跑够20年,我们可以使用64位整数表示时间,但为此我们的用户又得支付更多的 gas。真是个两难的设计啊!
如果你需要进行使用这些单位进行日期计算,需要特别小心,因为不是每年都是365天,且并不是每天都有24小时,因为还有闰秒。由于无法预测闰秒,必须由外部的oracle来更新从而得到一个精确的日历库(内部实现一个日期库也是消耗gas的)。
后缀不能用于变量。如果你想对输入的变量说明其不同的单位,可以使用下面的方式。
1 | pragma solidity ^0.4.0; |
内置特性
特殊变量及函数
区块和交易的属性:
- block.blockhash(uint blockNumber) returns (bytes32),给定区块号的哈希值,只支持最近256个区块,且不包含当前区块。
- block.coinbase (address) 当前块矿工的地址。
- block.difficulty (uint)当前块的难度。
- block.gaslimit (uint)当前块的gaslimit。
- block.number (uint)当前区块的块号。
- block.timestamp (uint)当前块的时间戳。
- msg.data (bytes)完整的调用数据(calldata)。
- msg.gas (uint)当前还剩的gas。
- msg.sender (address)当前调用发起人的地址,总是存在。
- msg.sig (bytes4)调用数据的前四个字节(函数标识符)。
- msg.value (uint)这个消息所附带的货币量,单位为wei。
- now (uint)当前块的时间戳,等同于block.timestamp
- tx.gasprice (uint) 交易的gas价格。
- tx.origin (address)交易的发送者(完整的调用链)
msg的所有成员值,如msg.sender,msg.value的值可以因为每一次外部函数调用,或库函数调用发生变化(因为msg就是和调用相关的全局变量)。如果你想在库函数中,用msg.sender实现访问控制,你需要将msg.sender做为参数(就是说不能使用默认的msg.value,因为它可能被更改)。为了可扩展性的原因,你只能查最近256个块,所有其它的将返回0.
数学和加密函数
- asser(bool condition):如果条件不满足,抛出异常。
- addmod(uint x, uint y, uint k) returns (uint):计算(x + y) % k。加法支持任意的精度。但不超过(wrap around?)2**256。
- mulmod(uint x, uint y, uint k) returns (uint):计算(x * y) % k。乘法支持任意精度,但不超过(wrap around?)2**256。
- keccak256(…) returns (bytes32):使用以太坊的(Keccak-256)计算HASH值。紧密打包。
- sha3(…) returns (bytes32):等同于keccak256()。紧密打包。
- sha256(…) returns (bytes32):使用SHA-256计算HASH值。紧密打包。
- ripemd160(…) returns (bytes20):使用RIPEMD-160计算HASH值。紧密打包。
- ecrecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) returns (address):通过签名信息恢复非对称加密算法公匙地址。如果出错会返回0,附录提供了一个例子1.
- revert():取消执行,并回撤状态变化。
地址相关
- .balance (uint256):Address的余额,以wei为单位。
- .transfer(uint256 amount):发送给定数量的ether,以wei为单位,到某个地址。失败时抛出异常。
- .send(uint256 amount) returns (bool):发送给定数量的ether,以wei为单位,到某个地址。失败时返回false。
- .call(...) returns (bool):发起底层的call调用。失败时返回false。
- .callcode(...) returns (bool):发起底层的callcode调用,失败时返回false。
- .delegatecall(...) returns (bool):发起底层的delegatecall调用,失败时返回false。
使用send方法需要注意,调用栈深不能超过1024,或gas不足,都将导致发送失败。使用为了保证你的ether安全,要始终检查返回结果。当用户取款时,使用transfer或使用最佳实践的模式2。
合约相关
- this(当前合约的类型):当前合约的类型,可以显式的转换为Address
- selfdestruct(address recipt):销毁当前合约,并把它所有资金发送到给定的地址。
进阶
控制语句
不支持switch和goto,支持if,else,while,do,for,break,continue,return,?:。条件判断中的括号不可省略,但在单行语句中的大括号可以省略。
函数调用
内部函数调用
在当前的合约中,函数可以直接调用(内部调用方式),包括也可递归调用。
外部函数调用
表达式this.g(8);和c.g(2)(这里的c是一个合约实例)是外部调用函数的方式。实现上是通过一个消息调用,而不是直接通过EVM的指令跳转。需要注意的是,在合约的构造器中,不能使用this调用函数,因为当前合约还没有创建完成。
其它合约的函数必须通过外部的方式调用。对于一个外部调用,所有函数的参数必须要拷贝到内存中。当调用其它合约的函数时,可以通过选项.value(),和.gas()来分别指定,要发送的ether量(以wei为单位),和gas值。
命名参数调用和匿名函数参数
函数调用的参数,可以通过指定名字的方式调用,但可以以任意的顺序,使用方式是{}包含。但参数的类型和数量要与定义一致。
1 | pragma solidity ^0.4.0; |
省略函数名称
没有使用的参数名可以省略(一般常见于返回值)。这些名字在栈(stack)上存在,但不可访问。
1 | pragma solidity ^0.4.0; |
创建合约实例
一个合约可以通过new关键字来创建一个合约。要创建合约的完整代码,必须提前知道,所以递归创建依赖是不可能的。
1 | pragma solidity ^0.4.0; |
从上面的例子可以看出来,可以在创建合约中,发送ether,但不能限制gas的使用。如果创建因为out-of-stack,或无足够的余额以及其它任何问题,会抛出一个异常。
赋值
Solidity内置支持元组(tuple),可以同时返回多个结果,也可用于同时赋值给多个变量。
1 | pragma solidity ^0.4.0; |
作用范围和声明
函数内定义的变量,在整个函数中均可用,无论它在哪里定义,因为Solidity使用了javascript的变量作用范围的规则。与常规语言语法从定义处开始,到当前块结束为止不同。由此,下述代码编译时会抛出一个异常,Identifier already declared。
1 | pragma solidity ^0.4.0; |
另外的,如果一个变量被声明了,它会在函数开始前被初始化为默认值。所以下述例子是合法的。
1 | pragma solidity ^0.4.0; |
随机数
在Solidity中无法安全地生成随机数,Solidity 中最好的随机数生成器是 keccak256 哈希函数,可以这样来生成一些随机数:
1 | // 生成一个0到100的随机数: |
这个方法可以被不诚实的节点攻击,假设我们有一个硬币翻转合约——正面你赢双倍钱,反面你输掉所有的钱。假如它使用上面的方法来决定是正面还是反面 (random >= 50 算正面, random < 50 算反面)。如果我正运行一个节点,我可以只对我自己的节点发布一个事务,且不分享它。我可以运行硬币翻转方法来偷窥我的输赢:如果我输了,我就不把这个事务包含进我要解决的下一个区块中去。我可以一直运行这个方法,直到我赢得了硬币翻转并解决了下一个区块,然后获利。
当然,这需要很大的算力来保证自己可以挖矿成功。
异常
可以使用throw来手动抛出一个异常。抛出异常的效果是当前的执行被终止且被撤销(值的改变和帐户余额的变化都会被回退)。异常还会通过Solidity的函数调用向上冒泡(bubbled up)传递。(send,和底层的函数调用call,delegatecall,callcode是一个例外,当发生异常时,这些函数返回false)。捕捉异常是不可能的。
require使得函数在执行过程中,当不满足某些条件时抛出错误,并停止执行:
1 | function sayHiToVitalik(string _name) public returns (string) { |
1 | pragma solidity ^0.4.0; |
用户可以通过下述方式触发一个异常:
- 调用throw。
- 调用require,但参数值为false。
通过assert判断内部条件是否达成,require验证输入的有效性。这样的分析工具,可以假设正确的输入,减少错误。这样无效的操作码将永远不会出现。
assert 和 require 区别在于,require 若失败则会返还给用户剩下的 gas, assert 则不会。
合约详解
合约
Solidity中合约有点类似面向对象语言中的类。合约中有用于数据持久化的状态变量(state variables),和可以操作他们的函数。调用另一个合约实例的函数时,会执行一个EVM函数调用,这个操作会切换执行时的上下文,这样,前一个合约的状态变量(state variables)就不能访问了。
合约可以通过Solidity,或不通过Solidity创建。当合约创建时,一个和合约同名的函数(构造器函数)会调用一次,用于初始化。构造器函数是可选的。仅能有一个构造器,所以不支持重载。
如果不通过Solidity,我们可以通过web3.js,使用JavaScript的API来完成合约创建。
当一个文件中有多个contract时,默认将合约名大写的那个合约当做主合约。
构造函数
修改为:
1 | contract Test { |
析构函数
1 | function kill() { |
可见性和权限控制
Solidity有两种函数调用方式,一种是内部调用,不会创建一个EVM调用(也叫做消息调用),另一种则是外部调用,会创建EVM调用(会发起消息调用)。Solidity对函数和状态变量提供了四种可见性。分别是external,public,internal,private。其中函数默认是public。状态变量默认的可见性是internal。
- external: 外部函数是合约接口的一部分,所以我们可以从其它合约或通过交易来发起调用。一个外部函数f,不能通过内部的方式来发起调用,(如f()不可以,但可以通过this.f())。外部函数在接收大的数组数据时更加有效。
- public: 可以在任何地方调用,不管是内部还是外部。对于public类型的状态变量,会自动创建一个访问器(详见下文)。
- internal:这样声明的函数和状态变量只能通过内部访问。如在当前合约中调用,或继承的合约里调用。需要注意的是不能加前缀this,前缀this是表示通过外部方式访问。
- private:私有函数和状态变量仅在当前合约中可以访问,在继承的合约内,不可访问。private修饰的函数名一般以”_”开头.
备注:
- internal 和 private 类似,不过, 如果某个合约继承自其父合约,这个合约即可以访问父合约中定义的“内部”函数。
- 所有在合约内的东西对外部的观察者来说都是可见,将某些东西标记为private仅仅阻止了其它合约来进行访问和修改,但并不能阻止其它人看到相关的信息。
- 可见性的标识符的定义位置,对于state variable是在类型后面,函数是在参数列表和返回关键字中间。来看一个定义的例子:
1 | pragma solidity ^0.4.0; |
在下面的例子中,D可以调用c.getData()来访问data的值,但不能调用f。合约E继承自C,所以它可以访问compute函数。
1 | pragma solidity ^0.4.0; |
访问函数
编译器为自动为所有的public的状态变量创建访问函数。下面的合约例子中,编译器会生成一个名叫data的无参,返回值是uint的类型的值data。状态变量的初始化可以在定义时完成。
1 | pragma solidity ^0.4.0; |
函数修饰符
modifier 可以用来改变一个函数的行为。比如用于在函数执行前检查某种前置条件。修改器是一种合约属性,可被继承,同时还可被派生的合约重写(override)。下面我们来看一段示例代码:
1 | pragma solidity ^0.4.0; |
- 修改器可以被继承,使用将modifier置于参数后,返回值前即可。
- 特殊_表示使用修改符的函数体的替换位置。
- 从合约Register可以看出全约可以多继承,通过
,
号分隔两个被继承的对象。 - 修改器也是可以接收参数的,如priced的costs。
使用修改器实现的一个防重复进入的例子。
1 | pragma solidity ^0.4.0; |
例子中,由于call()方法有可能会调回当前方法,修改器实现了防重入的检查。如果同一个函数有多个修改器,他们之间以空格隔开,修饰器会依次检查执行。需要注意的是,在Solidity的早期版本中,有修改器的函数,它的return语句的行为有些不同。在修改器中和函数体内的显式的return语句,仅仅跳出当前的修改器和函数体。返回的变量会被赋值,但整个执行逻辑会在前一个修改器后面定义的”_”后继续执行。修改器的参数可以是任意表达式。在对应的上下文中,所有的函数中引入的符号,在修改器中均可见。但修改器中引入的符号在函数中不可见,因为它们有可能被重写。
常量
常量
状态变量可以被定义为constant,常量。这样的话,它必须在编译期间通过一个表达式赋值。赋值的表达式不允许:1)访问storage;2)区块链数据,如now,this.balance,block.number;3)合约执行的中间数据,如msg.gas;4)向外部合约发起调用。也许会造成内存分配副作用表达式是允许的,但不允许产生其它内存对象的副作用的表达式。内置的函数keccak256,keccak256,ripemd160,ecrecover,addmod,mulmod可以允许调用,即使它们是调用的外部合约。
允许内存分配,从而带来可能的副作用的原因是因为这将允许构建复杂的对象,比如,查找表。虽然当前的特性尚未完整支持。
编译器并不会为常量在storage上预留空间,每个使用的常量都会被对应的常量表达式所替换(也许优化器会直接替换为常量表达式的结果值)。
不是所有的类型都支持常量,当前支持的仅有值类型和字符串。
新版本中:
- view: 把函数定义为 view, 意味着它只能读取数据不能更改数据
- pure: pure 函数表明这个函数不访问应用里的数据
1 | pragma solidity ^0.4.0; |
常函数
函数也可被声明为常量,这类函数将承诺自己不修改区块链上任何状态。
1 | pragma solidity ^0.4.0; |
访问器(Accessor)方法默认被标记为constant。当前编译器并未强制一个constant的方法不能修改状态。但建议大家对于不会修改数据的标记为constant。
回退函数
每一个合约有且仅有一个没有名字的函数。这个函数无参数,也无返回值。如果调用合约时,没有匹配上任何一个函数(或者没有传哪怕一点数据),就会调用默认的回退函数。此外,当合约收到ether时(没有任何其它数据),这个函数也会被执行。在此时,一般仅有少量的gas剩余,用于执行这个函数(准确的说,还剩2300gas)。所以应该尽量保证回退函数使用少的gas。
下述提供给回退函数可执行的操作会比常规的花费得多一点。
- 写入到存储(storage)
- 创建一个合约
- 执行一个外部(external)函数调用,会花费非常多的gas
- 发送ether
请在部署合约到网络前,保证透彻的测试你的回退函数,来保证函数执行的花费控制在2300gas以内。一个没有定义一个回退函数的合约。如果接收ether,会触发异常,并返还ether(solidity v0.4.0开始)。所以合约要接收ether,必须实现回退函数。下面来看个例子:
1 | pragma solidity ^0.4.0; |
payable
1 | contract OnlineStore { |
提现
在发送以太之后,它将被存储进合约的以太坊账户中,并冻结在哪里,可以写一个函数来从合约中提现以太,类似这样:
1 | contract GetPaid is Ownable { |
事件
事件是合约和区块链通讯的一种机制。前端应用“监听”某些事件,并做出反应。(触发事件需要添加emit关键字)
例子:
1 | // 这里建立事件 |
app前端可以监听这个事件,JavaScript 实现如下:
1 | YourContract.IntegersAdded(function(error, result) { |
继承
1 | pragma solidity ^0.4.0; |
基类构造器的方法
派生的合约需要提供所有父合约需要的所有参数,所以用两种方式来做,见下面的例子:
1 | pragma solidity ^0.4.0; |
或者直接在继承列表中使用is Base(7),或像修改器(modifier)使用方式一样,做为派生构造器定义头的一部分Base(_y * _y)。第一种方式对于构造器是常量的情况比较方便,可以大概说明合约的行为。第二种方式适用于构造的参数值由派生合约的指定的情况。在上述两种都用的情况下,第二种方式优先(一般情况只用其中一种方式就好了)。
继承有相同名字的不同类型成员
当继承最终导致一个合约同时存在多个相同名字的修改器或函数,它将被视为一个错误。同新的如果事件与修改器重名,或者函数与事件重名都将产生错误。作为一个例外,状态变量的getter可以覆盖一个public的函数。
抽象(Abstract Contracts)
抽象函数是没有函数体的的函数。如下:
1 | pragma solidity ^0.4.0; |
这样的合约不能通过编译,即使合约内也包含一些正常的函数。但它们可以做为基合约被继承。
1 | pragma solidity ^0.4.0; |
如果一个合约从一个抽象合约里继承,但却没实现所有函数,那么它也是一个抽象合约。
接口
接口与抽象合约类似,与之不同的是,接口内没有任何函数是已实现的,同时还有如下限制:
- 不能继承其它合约,或接口。
- 不能定义构造器
- 不能定义变量
- 不能定义结构体
- 不能定义枚举类
- 其中的一些限制可能在未来放开。
接口基本上限制为合约ABI定义可以表示的内容,ABI和接口定义之间的转换应该是可能的,不会有任何信息丢失。接口用自己的关键词表示:
1 | interface Token { |
合约可以继承于接口,因为他们可以继承于其它的合约。
Gas优化
- 结构体中尽量使用uint的少位数,且考虑字节对其
- “view” 函数不花 “gas”,这是因为 view 函数不会真正改变区块链上的任何数据。如果一个 view 函数在另一个函数的内部被调用,而调用函数与 view 函数的不属于同一个合约,也会产生调用成本。这是因为如果主调函数在以太坊创建了一个事务,它仍然需要逐个节点去验证。所以标记为 view 的函数只有在外部调用时才是免费的(当玩家从web.js外部调用)。
- 在大多数编程语言中,遍历大数据集合都是昂贵的。但是在 Solidity 中,使用一个标记了external view的函数,遍历比 storage 要便宜太多,因为 view 函数不会产生任何花销。
- 为了降低成本,不到万不得已,避免将数据写入存储。这也会导致效率低下的编程逻辑:比如每次调用一个函数,都需要在 memory(内存) 中重建一个数组,而不是简单地将上次计算的数组给存储下来以便快速查找。
智能合约升级
将合约进行分离,先部署数据合约,部署后不变。业务合约调用数据合约读取修改数据。保证数据类型不变的情况下,可以对业务合约进行修改,新增。之后部署新的业务合约,废除旧的业务合约。
1 | pragma solidity ^0.4.18; |
部署方法如下:
- 先部署DataContract合约。
- 使用DataContract合约地址作为部署ControlContract合约的参数。
- 用ControlContract合约地址作为参数调用DataContract合约的allowAccess方法。
如果需要更新控制合约(如修复了addTen)则重新执行第2-3步,同时对老的控制合约执行denyAccess()。
代币
一个代币在以太坊上就是一个遵循一些共同规则的智能合约:以太坊代币标准(ERC-Token Standard)。建立在以太坊网络上的区块链项目代币,需要遵从以下几种代币标准:ERC-20,ERC-223,ERC-621,ERC-721,ERC-827。其中 ERC 是 Ethereum Request for Comments 的简称。
ERC-20
这是最广泛被大家认可的一种代币形式,简单的列举一些通用的标准函数:
- function totalSupply() 定义 Token 的总量;
- function balanceOf(address tokenOwner) 显示用户账户余额;
- function allowance(address tokenOwner, address spender) 返回剩余金额,显示 address spender 能从 address tokenOwner 里提取的数量;
- function transfer(address to, uint tokens) 转移对应的金额到指定地址;
- function approve(address spender, uint tokens) returns (bool success) 允许 address spender 提取部分 Token;
- function transferFrom(address from, address to, uint tokens) returns (bool success) 从一个地址转移 token 到另一个地址;
拥有以上所有必要的函数实现我们称为兼容 ERC-20 标准,但在具体实现中会做一些扩展,比如 ERC-223。
ERC-223
这个标准支持所有 ERC-20 的函数、智能合约以及服务,并解决了一些 ERC-20 的缺陷,比如说:在 ERC-20 标准下如果你输入了错误的收款地址,你转账的费用可能会永远丢失,但在 ERC-223 里这个问题被避免了,同时在这个标准下你需要消耗的 GAS 费用只有 ERC-20 的一半。
ERC-621
ERC-621 也是一个基于 ERC-20 升级的标准,解决了 ERC-20 不允许 Token 总量更改的问题,不过为了解决这个问题,ERC-621 增加了两种新的函数:
increaseSupply 和 decreaseSupply
ERC-827
这个标准比 ERC-20 更加灵活,除用于转账外,还可以转移数据和让第三方在获取用户允许的情况下为用户转账。
ERC-721
ERC-721 与 ERC-20 有很大的区别,如果说 ERC-20 与 ERC-223,ERC-621 能够在使用中自由转换的话,ERC-721 是不可与 ERC-20 Token 互相转换的,因为 ERC-721 拥有唯一性。这种 Token 依然可以在交易所里交易,只不过无法分割是一个独立的整体。
1 | contract ERC721 { |
ERC721 规范有两种不同的方法来转移代币:
- 第一种方法是代币的拥有者调用transfer 方法,传入他想转移到的 address 和他想转移的代币的 _tokenId。
- 第二种方法是代币拥有者首先调用 approve,然后传入与以上相同的参数。接着,该合约会存储谁被允许提取代币,通常存储到一个 mapping (uint256 => address) 里。然后,当有人调用 takeOwnership 时,合约会检查 msg.sender 是否得到拥有者的批准来提取代币,如果是,则将代币转移给他。
- 所有者,用新主人的 address 和你希望他获取的 _tokenId 来调用 approve
- 新主人用 _tokenId 来调用 takeOwnership,合约会检查确保他获得了批准,然后把代币转移给他。
库
Ownable
1 | /** |
SafeMath
1 | library SafeMath { |
SafeMath 库允许使用 using 关键字,它可以自动把库的所有方法添加给一个数据类型:
1 | using SafeMath for uint; |
所以简而言之, SafeMath 的 add, sub, mul, 和 div 方法只做简单的四则运算,然后在发生溢出或下溢的时候抛出错误。