Solidity

Solidity基础

前言:最近新工作的主要业务是区块链相关,从五月开始就开始研究起了区块链相关的东西,solidity主要是用来写一些智能合约,在自己学习的过程中产出次文档,文档中有很多不全或者错误,大家海涵!

​ Solidity是一种智能合约高级语言,运行在Ethereum虚拟机(EVM)之上。Solidity 是一种面向对象的高级语言,用于实现智能合约。智能合约是管理以太坊状态内账户行为的程序。

​ 它受到 C++、Python 和 JavaScript 的影响。但作为一种真正意义上运行在网络上的去中心合约,它又有很多的不同,下面列举一些:

  • 以太坊底层是基于帐户,而非UTXO的,所以有一个特殊的Address的类型。用于定位用户,定位合约,定位合约的代码(合约本身也是一个帐户)。

  • 由于语言内嵌框架是支持支付的,所以提供了一些关键字,如payable,可以在语言层面直接支持支付,而且超级简单。

  • 存储是使用网络上的区块链,数据的每一个状态都可以永久存储,所以需要确定变量使用内存,还是区块链。

  • 运行环境是在去中心化的网络上,会比较强调合约或函数执行的调用的方式。因为原来一个简单的函数调用变为了一个网络上的节点中的代码执行,分布式的感觉。

  • 最后一个非常大的不同则是它的异常机制,一旦出现异常,所有的执行都将会被回撤,这主要是为了保证合约执行的原子性,以避免中间状态出现的数据不一致。

    ​ 您可以使用Remix IDE直接在浏览器中试用代码示例。Remix 是一个基于 Web 浏览器的 IDE,允许您编写、部署和管理 Solidity 智能合约,而无需在本地安装 Solidity。

    Remix地址:https://remix.ethereum.org/

solidity数据类型

​ Solidity是静态类型语言,常见的静态类型语言有C、C++、Java等,静态类型意味着在编译时需要为每个变量(本地或状态变量)都指定类型。

​ Solidity数据类型看起来很简单,但却是最容易出现漏洞(如发生“溢出”等问题)。还有一点需要关注,Solidity的数据类型非常在意所占空间的大小。另外,Solidity的一些基本数据类型可以组合成复杂数据类型。

Solidity数据类型可分为两大类:

  • 值类型(Value Type)
  • 引用类型(Reference Type)

值类型

值类型变量用表示可以用32个字节来存储的数据,他们在赋值或传参时,总是进行值拷贝。

值类型包含:

  1. 布尔类型(Booleans)
  2. 整型(Integers)
  3. 定长浮点型(Fixed point Numbers)
  4. 定长字节数组(Fixed -size byte arrays)
  5. 有理数和整型常量(Rational and Integer Literals)
  6. 字符串常量(String Literals)
  7. 十六进制常量(Hexadecimal literals)
  8. 枚举(Enums)
  9. 合约类型(Contract)
  10. 函数类型(Function Types0)
  11. 地址类型(Address)
  12. 地址常量(Address Literals)

本文重点介绍整型,地址类型,合约类型和函数类型这三种常用类型。

各类型详细介绍可参考:https://learnblockchain.cn/docs/solidity/types.html

整型

int / uint :分别表示有符号和无符号的不同位数的整型变量。 支持关键字 uint8uint256 (无符号,从 8 位到 256 位)以及 int8int256,以 8 位为步长递增。 uintint 分别是 uint256int256 的别名。

运算符:

运算类型 运算符 备注
比较运算 <= , < , == , != , >=, > 返回布尔值
位运算 &(与),|(或),^(异或),~(位取反)
移位运算 <<(左移位),>>(右移位)
算数运算 + , - , - (一元运算负,仅针对有符号整型), * , / , % (取余或叫模运算) , **(幂)

tips:

对于整形 X,可以使用 type(X).mintype(X).max 去获取这个类型的最小值与最大值。

注意:

Solidity中的整数是有取值范围的。 例如 uint32 类型的取值范围是 02 ** 32-1 。 0.8.0 开始,算术运算有两个计算模式:一个是 “wrapping”(截断)模式或称 “unchecked”(不检查)模式,一个是”checked” (检查)模式。 默认情况下,算术运算在 “checked” 模式下,即都会进行溢出检查,如果结果落在取值范围之外,调用会通过失败异常回退。 你也可以通过 unchecked { ... } 切换到 “unchecked”模式

整型的运算符基本上和Java的运算差不多,主要需要注意取值范围的问题,容易出现整型溢出的问题。还有一个需要注意的就是幂运算中的0**0是等于1的。

地址类型

在Solidity中,使用地址类型来表示一个账号,地址类型有两种形式。

  • address:保存一个20字节的值(以太坊地址大小)
  • address payable:表示可支付地址,与address相同也是20个字节,不过它有成员函数transfer和send

这种区别背后的思想是 address payable 可以接受以太币的地址,而一个普通的 address 则不能。

类型转换:

允许从 address payableaddress 的隐式转换,而从 addressaddress payable 必须显示的转换, 通过 payable() 进行转换 。

// 0.6版本
address payable ap = payable(addr);
// 0.5版本
address payable ap = address(uint160(addr));

address 允许和 uint160、 整型字面常量、bytes20 及合约类型相互转换。

注意:

addressaddress payable 的区别是在 0.5.0 版本引入的,同样从这个版本开始,合约类型不再继承自地址类型,

不过如果合约有可支付的回退( payable fallback )函数或receive 函数,合约类型仍然可以显示转换为

addressaddress payable

比较运算:

<=,<,==,!=,>=,>

比较常用的就是==和!=

地址类型成员:

地址类型和整型等基本类型不同,地址类型还有自己的成员属性和函数

名称 说明 成员性质
.balance(uint(256))
返回地址类型address的余额 成员属性
.transfer(uint256 amount)
用来向地址发送amount数量wei的以太币,失败时抛出异常,消耗固定的2300gas 成员函数
.send(uint256 amount) returns(bool)
向地址发送amount数量wei的以太币,失败时返回false,消耗固定的2300gas。addr.transfer(y)等价于require(addr.send(y))

注意:(send为低级版本,大部分情况下应该使用transfer)
成员函数

合约类型

合约类型用contract关键字定义,每个contract定义都有他自己的类型。

  • 合约类型和Java中的类相似,每个类都是一个类型,可以通过<具体的合约类型>.合约类型成员函数的方式调用合约中的函数。
  • 合约可以显示转换为address类型,从而可以使用地址类型的成员变量
  • 在合约内部可以使用this关键字表示当前合约,可以通过address(this)转换为一个地址类型

合约类型信息:

Solidity从0.6版本开始,对于合约C,可以通过type©来获取合约的类型信息。

  • type©.name:获得合约的名字
  • type©.creationCode:获得创建合约时的字节码
  • type©.runtimeCode:获得合约运行时的字节码

函数类型

Solidity中的函数也可以是一种类型,并且它属于值类型,用function关键字修饰,可以将一个函数赋值给一个函数类型的变量,也可以将一个函数作为参数进行传递,还可以在函数调用中返回一个函数。

函数类型分为两类:

  • 内部函数(internal)
  • 外部函数(external)

函数类型成员:

公有或外部(public/external)函数类型有以下成员属性和方法

  • .address:返回函数所在的合约地址
  • .selector:返回ABI函数选择器

函数参数:

与Javascript一样,函数可能需要参数作为输入。而与Javascript和C不同的是,它们可能返回任意数量的参数作输出

函数参数的声明方式和变量相同,不过没使用的参数可以省略参数名。

函数参数可以当作本地变量,也可以在等号左边被赋值。

外部函数不可以接受多维数组作为参数。

返回变量:

函数返回变量的声明方式在关键词returns之后,与参数的声明方式相同。

可以使用return关键字返回一个或者多个值。当函数需要使用多个值,可以使用语句return(v0,v1,…,vn)。参数的数量需要和声明的时候一致。

非内部函数有些数据类型没法返回,比如限制的类型有:多维动态数组,结构体等。

引用类型

值类型的变量,赋值时总是进行完整独立的拷贝。而一些复杂类型,如数组和结构体,占用空间通常超过256位(32个字节),拷贝时开销很大,这时就可以使用引用的方式,即通过多个不同名称的变量指向一个值。目前,应用类型包括结构,数组和映射

注意:

引用类型可以通过多个不同的名称修改它的值,而值类型的变量,每次都有独立的副本。因此,必须比值类型更谨慎地处理应用类型(有点类似于Java中的多线程共享数据的问题)。如果使用引用类型,则必须明确指明数据存储那种类型的位置(空间)里。更改数据位置或类型转换将始终产生自动进行一份拷贝,而在同一数据位置内的复制仅在某些情况下进行拷贝。

数据位置:

引用类型都有一个额外属性来标识数据的存储位置,因此在使用引用类型时,必须明确指明数据存储于哪种类型的位置(空间)里,EVM中一共有三种位置。

  • memory(内存):其生命周期只存在于函数调用期间,局部变量默认存储在内存,不能用于外部调用。
  • storage(存储):状态变量保存的位置,只要合约存在就一直保存在区块链中。
  • calldata(调用数据):用来存储函数参数的特殊数据位置,它是一个不可修改的,非持久的函数参数存储区域。

注意:

callable(调用数据)是外部函数的参数所必需指定的位置,但也可以用于其他变量。如果可以的话请尽量使用callable作为数据位置,因为它将避免复制,并确保不能修改数据。函数的返回值中也可以使用callable数据位置的数组和结构,但无法给其分配空间。

数据位置于赋值行为:

  • 在 存储storage 和 内存memory 之间两两赋值(或者从 调用数据calldata 赋值 ),都会创建一份独立的拷贝。
  • 从 内存memory 到 内存memory 的赋值只创建引用, 这意味着更改内存变量,其他引用相同数据的所有其他内存变量的值也会跟着改变。
  • 从 存储storage 到本地存储变量的赋值也只分配一个引用。
  • 其他的向 存储storage 的赋值,总是进行拷贝。 这种情况的示例如对状态变量或 存储storage 的结构体类型的局部变量成员的赋值,即使局部变量本身是一个引用,也会进行一份拷贝
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;

contract Tiny {
    uint[] x; // x 的数据存储位置是 storage, 位置可以忽略

    // memoryArray 的数据存储位置是 memory
    function f(uint[] memory memoryArray) public {
        x = memoryArray; // 将整个数组拷贝到 storage 中,可行
        uint[] storage y = x;  // 分配一个指针(其中 y 的数据存储位置是 storage),可行
        y[7]; // 返回第 8 个元素,可行
        y.pop(); // 通过 y 修改 x,可行
        delete x; // 清除数组,同时修改 y,可行

        // 下面的就不可行了;需要在 storage 中创建新的未命名的临时数组,
        // 但 storage 是“静态”分配的:
        // y = memoryArray;
        // 下面这一行也不可行,因为这会“重置”指针,
        // 但并没有可以让它指向的合适的存储位置。
        // delete y;

        g(x); // 调用 g 函数,同时移交对 x 的引用
        h(x); // 调用 h 函数,同时在 memory 中创建一个独立的临时拷贝
    }

    function g(uint[] storage ) internal pure {}
    function h(uint[] memory) public pure {}
}

数组

数组可以在声明时指定长度,也可以动态调整大小(长度)。

一个元素类型为 T,固定长度为 k 的数组可以声明为 T[k],而动态数组声明为 T[]。 举个例子,一个长度为 5,元素类型为 uint 的动态数组的数组(二维数组),应声明为 uint[][5] (注意这里跟其它语言比,数组长度的声明位置是反的)。

数组下标是从0开始的,且访问数组时的下标顺序与声明时相反。

如:如果有一个变量为 uint[][5] memory x, 要访问第三个动态数组的第二个元素,使用 x[2][1],要访问第三个动态数组使用 x[2]。 同样,如果有一个 T 类型的数组 T[5] a , T 也可以是一个数组,那么 a[2] 总会是 T 类型。

bytes和strings

bytesstring 类型的变量是特殊的数组。 bytes 类似于 byte[],但它在 调用数据calldata 和 内存memory 中会被“紧打包”(将元素连续地存在一起,不会按每 32 字节一单元的方式来存放)。 stringbytes 相同,但不允许用长度或索引来访问。 bytes和string都可以用来表达字符串,对任意长度的原始字节数据使用bytes,对任意长度字符串(UTF-8)数据使用string。

映射

映射类型和Java的Map、Python的Dict在功能上差不多,它是一种键值对的映射关系存储结构。映射是一种使用广泛的类型,经常在合约中充当一个类似数据库的角色,比如在代币合约中映射来存储账号的余额,在游戏合约中可以用来存储每个账号的级别。

映射类型在声明时的形式为 mapping(_KeyType => _ValueType)。 其中 _KeyType 可以是任何基本类型,即可以是任何的内建类型, bytesstring 或合约类型、枚举类型。 而其他用户定义的类型或复杂的类型如:映射、结构体、即除 bytesstring 之外的数组类型是不可以作为 _KeyType 的类型的 。 _ValueType 可以是包括映射类型在内的任何类型。

结构体

Solidity可以使用struct关键字来定义一个自定义类型。除了可以使用借本类型作为成员以外,还可以使用数组、结构体、映射作为成员。不能在声明结构体的同时将自身结构体作为成员,但可以将它作为结构体中映射的值类型。

合约

Solidity 合约类似于面向对象语言中的类,使用contract关键字来声明一个合约。合约中有用于数据持久化的状态变量,和可以修改状态变量的函数。 调用另一个合约实例的函数时,会执行一个 EVM 函数调用,这个操作会切换执行时的上下文,这样,前一个合约的状态变量就不能访问了。

可见性

跟其他很多语言一样,Solidity使用public、private关键字来控制变量和函数是否可以被外部使用。Solidity提供了4种可见性来修饰函数及状态变量,分别是:external(不修饰状态变量)、public、internal、private。

不同的可见性还会对函数调用方式产生影响,Solidity有两种函数调用:

  • 内部调用:代码调转,直接使用函数名调用
  • 外部调用:合约之外的调用(其他合约或者web3jAPI),也成为消息调用或者EVM调用,调用形式为c.f()

4种可见性的解释:

  • external:我们把external修饰的函数称为外部函数,外部函数是合约接口的一部分,所以我们可以从其他合约或通过交易来发起调用。一个外部函数f()不能通过内部的方式来发起调用,即不可以使用f()发起调用,只能使用this.f()发起调用。
  • public:我们把public修饰的函数成为公开函数,公开函数也是合约接口的一部分,它可以同时支持内部调用以及外部调用。对于public类型的状态变量,Solidity还会自动创建一个访问器函数,这是一个与状态变量名字相同的函数,用来获取状态变量的值。
  • internal:internal声明的函数和状态变量只能在当前合约中调用或者在继承的合约里访问,也就是说只能通过内部调用的方式访问。
  • private:private函数和状态变量仅在当前定义他们的合约中使用,并且不能被派生合约使用。**注意:**所有在合约内的内容,在链层面都是可见的,将某些函数或变量标记为private仅仅阻止其他合约来进行访问和修改,但并不能阻止其他人看到相关的信息

可见性标识符的位置:

对于变量来说是在类型的后面,对于函数来说是在参数列表和返回关键字中间

pragma solidity  >=0.4.16 <0.9.0;

contract C {
    function f(uint a) private pure returns (uint b) { return a + 1; }
    function setData(uint a) internal { data = a; }
    uint public data;
}

构造函数

构造函数是使用constructor关键字声明的一个函数,它在创建合约时执行,用来运行合约初始化代码,如果没有初始化代码也可以省略(此时,编译器会添加一个默认的构造器函数constructor() public {})。

构造器函数可以是公有函数public,也可以是内部函数internal,当构造器函数作为internal时,表示此合约不可以部署,仅仅作为一个抽象合约。

constant状态常量

状态变量可以被声明为constant。编译器并不会为常量在storage上预留空间,而是在编译时使用对应的表达式值替换变量。

pragma solidity >=0.4.0 <0.7.0;

contract C {
	uint constant x = 32 * 22 + 8;
	string constant text = "abc";
}

如果是在编译期不能确定表达式的值,则无法给constant修饰的变量赋值,例如一些获取链上状态的表达式:new、address(this).balance、block.number、msg.value、gasleft()等是不可以的。

不过对于内建函数,如keccak256、sha256、ripemd160、ecrecover、addmod和mulmod是允许的,因为这些函数运算的结构在编译时就可以确定;

constant目前仅支持修饰字符串及值类型。

immutable不可变量

immutable修饰的变量是在部署的时候确定变量的值,它在构造函数中赋值一次之后就不再改变,这是一个运行时赋值,就可以解除之前constant不支持使用运行时状态赋值的限制。

immutable不可变量同样不会占用状态变量存储空间,在部署时,变量的值会被追加到运行时的字节码中,因此它比使用状态变量便宜的多,同样带来了更多的安全性(确保了这个值无法再修改)

view视图函数

可以将函数声明为view类型,这种情况下要保证不修改状态。

下面的语句被认为是修改状态:

  1. 修改状态变量
  2. 产生事件
  3. 创建其他合约
  4. 使用selfdestruct
  5. 通过调用发送以太币
  6. 通过任何没有标记为view或者pure的函数
  7. 使用低级调用
  8. 使用包含特定操作码的内联汇编

注意:

Getter方法自动被标记为view

pure纯函数

函数可以声明为pure,表示函数不读取也不修改状态。除了view列举的修改状态外,以下操作被认为是读取状态

  1. 读取状态变量
  2. 访问address(this).balance或者.balance
  3. 访问block、tx、msg中任意成员(除msg.sig和msg.data之外)
  4. 调用任何未标记为pure的函数
  5. 使用包含某些操作码的内联汇编

访问器函数(getter)

对于public类型的状态变量,Solidity编译器会自动为合约创建一个访问器函数,这是一个与状态变量名字相同的函数,用来获取状态变量的值(不用再额外写函数获取变量的值)

值类型:

如果状态变量的类型是基本(值)类型,会生成一个同名的无参数的external的试图函数。

uint public data;
//会生成下面的函数
function data() external view returns ()uint {
	
}

数组:

对于状态变量标记为public的数组,会生成带参数的访问器函数,参数会访问数组的下标索引,即只能通过生成的访问器函数访问数组的单个元素,如果是多维数组,会有多个参数。

uint[] public myArray;
//会生成下面的函数
function myArray(uint i) external view returns (uint) {
	return myArray[i];
}
//如果我们需要返回整个数组,需要额外添加函数
//返回整个数组方法
function getArray() external view returns (uint[] memory) {
	return maArray;
}

映射:

对于状态变量标记为public的映射类型,其处理方式和数组一致,参数是键类型,返回值类型。

mapping (uint => uint) public idScore;
//会生产函数
function inScore(uint i) external returns (uint) {
	return idScore[i];
}

//一个稍微复杂一点的例子(嵌套映射)
pragma solidity ^0.4.0 <0.7.0;
contract complex{
	struct Data{
		uint a;
		bytes3 b;
		mapping (uint => uint) map;
	}
	mapping (uint => mapping(bool => Data[])) public data;
}
//data变量会生成以下函数
function data (uint arg1,bool arg2,uint arg3) external returns (uint a, bytes3 b){
	a = data[arg1][arg2][arg3].a;
	b = data[arg1][arg2][arg3].b;
}

receive接收以太函数

合约的receive(接收)函数是一个特殊的函数,表示合约可以用来接收以太币的转账,一个合约最多有一个接收函数,接收函数声明为:

receive() external payable{...}

函数名只有一个receive关键字,而不需要function关键字,也没有参数和返回值,并且必须具备外部可见性(external)和可支付(payable),它可以是virtual的,也可以被重载,也可以有修改器(modifier)

在对合约没有任何附加数据调用时(通常是对合约转账)就会执行receive函数,例如通过addr.send()或者addr.transfer()调用时,就会执行合约的receive函数。

如果合约中没有定义receive函数,但是定义了payable修饰的fallback函数,那么在进行以太转账时,fallback函数会被调用。如果receive函数和fallback函数都没有,这个合约就没办法通过转账交易接收以太币(转账交易会抛出异常)。但是有一个例外,没有定义receive函数的合约,可以作为coinbas交易(矿工区块回报交易)的接受者或者作为selfdestruct(销毁合约)的目标来接收以太币。

receive函数可能只有2300gas可以使用,(如:当使用send()或transfer时),出了基础的日志输出之外,进行其他操作的余地很小。下面的操作会消耗2300gas:

  • 写入存储
  • 创建合约
  • 调用消耗大量gas的外部函数
  • 发送以太币

fallback函数(回退函数)

fallback函数也是一个特殊的函数,一般中文名称为“回退函数“,一个合约最多有一个fallback函数。fallbakc函数的声明如下:

fallback() external payable {...}

tips:

在老的solidity0.6里,回退函数是一个无名函(没有函数名的函数),如果你看到一些老合约代码出现没有名字的函数,不用感到奇怪,它就是回退函数。

这个函数无参数,也无返回值,也没有function关键字,必须满足external可见性。

如果对合约函数进行调用,而合约并没有实现对应的函数,那么fallback函数会被调用。或者是对合约转账,而合约又没有实现receive函数,那么此时标记为payable的fallback函数就会被调用。

需要注意的是,当在合约中使用,send(和transfe)向合约转账时,仅仅会提供2300gas来执行,如果receive或者fallback函数的实现需要较多的运算量,会导致转账失败。特别需要说明的是,以下操作会消耗大于2300gas:

  • 写入存储
  • 创建合约
  • 调用消耗大量gas的外部函数
  • 发送以太币

函数修改器

函数修改器,可以用来改变函数的行为,比如用于在函数执行前检查某种前置条件。

函数修改器使用关键字modifier,以下代码定义了onlyOwner函数修改器。onlyOwner函数修改器定义了一个验证:要求函数的调用者必须是合约的创建者,onlyOwner的实现中使用了require函数

pragma solidity >=0.5.0 <0.7.0

contract owned{
    function owned() public {
        owner = msg.sender();
    }
    address owner;

    modifier onlyOwner{
        require(
            msg.sender == owner,
            "Only owner can call this function."
        );
        _;
    }

    function transferOwner(address _newO) public onlyOwner{
        owner = _newO;
    }


}

上面使用函数修改器onlyOwner修饰了transferOwner(),这样的话,只有在满足创建者的情况下才能成功调用transferOwner()。

函数修改器一般带有特殊符号’';",修改器所修饰的函数体会被插入到’';"的位置,因此transferOwner扩展开就是:

function transferOwner(address _newO) public onlyOwner{
	require(
            msg.sender == owner,
            "Only owner can call this function."
        );
    owner = _newO;
    }

修改器有以下几个特性:

  • 修改器可继承
  • 修改器可带参数
  • 一个函数多个修改器

说明:

一个函数多个修改器是指一个函数也可以被多个函数修改器修饰,多个修改器之间的执行顺序是从左到右的。另外,修改器或者函数体中显示的return语句仅仅跳出当前的修改器或者函数体,整个执行逻辑会在前一个修饰器定义的_;之后继续执行。

函数重载(Function Overloading)

合约可以具有多个包含不同参数的同名函数,被称为重载(overloading),需要注意的是,重载外部函数需要保证参数在ABI接口层面是不同的。

以下代码无法编译:

pragma solidity >=0.4.16 <0.9.0;

contract A {
    function f(B _in) public pure returns (B out) {
        out = _in;
    }

    function f(address _in) public pure returns (address out) {
        out = _in;
    }
}

contract B {
}

以上两个f()函数重载时,一个使用合约类型,一个使用地址类型,但对外的ABI表示时,都会被认为是地址类型,因此无法实现重载。

函数返回多个值

solidity内置支持元祖(tuple),它是一个由数量固定,类型可以不同的元素组成的一个列表。使用元祖可以返回多个值,也可以用于同时赋值给多个变量。

事件Events

事件(Event)是合约与外部一个很重要的接口,当我们向合约发起一个交易时,这个交易时在链上异步执行的,无法立即知道执行结果,通过在执行过程中触发某个事件,可以把执行的状态变化通知到外部(需要外部监听事件变化)

事件是通过关键字event来声明的,event不需要实现,我们可以认为事件是一个用来被监听的接口。

错误处理及异常

Solidity处理错误和Java等语言的方式有些不一样。Solidity是通过回退状态的方式来处理错误的,即如果合约在运行时发生了异常,则会撤销当前交易所有调用(包含子调用)所改变的状态,同时给调用者返回一个错误处理标识。

assert()

用来检查(测试)内部错误,发生了错误说明程序出现了bug。

  • assert(bool condition):如果不满足条件,会导致无效的操作码,撤销状态更改,主要用于检查内部错误。

require()

用来检查输入变量或者合约状态变量是否满足条件,以及验证调用外部合约的返回值。

  • require(bool condition):如果不满足条件,则撤销状态更改,主要用于检查有输入或者外部组件引起的错误。
  • require(bool condition,string memory message):如果条件不满足,则撤销状态更改,主要用于检查有输入或者外部组件引起的错误,可以同时提供一个错误消息。

revert()

用来标记错误并恢复当前调用。

  • revert():终止运行并撤销状态更改
  • revert(string memory reason):终止运行并撤销状态更改,可以同时提供一个解释性的字符串。

require还是assert

在EVM里,处理assert和require两种异常的方式是不一样的,虽然他们都会回退状态,不同点表现在:

  1. gas消耗不同。assert类型的异常会消耗掉所有剩余的gas,而require不会消耗掉剩余的gas(剩余的gas会返还给调用者)
  2. 操作符不同。当assert发生异常时,Solidity会执行一个无效操作(无效指令0xfe)。当发生require类型的异常时,Solidity会执行一个回退操作(REVERT指令0xfd)
  • 优先使用require()
    1. 用于检查用户输入。
    2. 用于检查合约调用返回值,如require(external.send(amount))。
    3. 用于检查状态,如msg.send == owner。
    4. 通常用于函数开头。
    5. 不知道使用哪一个的时候,就使用require。
  • 优先使用assert()
    1. 用于检查溢出错误,如z = x + y; assert(z >= x);
    2. 用于检查不应该发生的异常情况。
    3. 用于在状态改变之后,检查合约状态。
    4. 尽量少使用assert。
    5. 通常用于函数中间或者尾部。

try/catch

在Solidity0.6版本之后,加入try/catch来捕获外部调用的异常,让我们在编写智能合约时,有更多的灵活性。在以下的场景中很有用:

  • 如果一个调用回滚了(revert),我们不想终止交易的执行
  • 我们想在同一个交易中重试调用、存储错误状态、对失败的调用做处理等
pragma solidity <0.6.0;

contract OldTryCatch{
    function execute (uint256 amount) external{
        try this.onlyEvent(amount){
            ...
        } catch {
            ...    
        }
    }

    function onlyEvent (uint256 a) public {
        //code that can revert
        require(a % 2 == 0, "Ups! Reverting");
    }
}

注意:

try/catch仅适用于外部调用,因此上面调用this.onlyEvent(),另外try大括号内的代码是不能被catch捕获的。

Solidity进阶

上一篇我们整理了Solidity的一些基本语法,下面我们将接着了解合约的继承、接口、库的使用。另外还会介绍一些平时开发不怎么使用的ABI。了解这些方便与以后我们理解合约运行以及阅读他人的代码。

继承

继承是大多数高级语言都具有的特性,Solidity同样支持继承,Solidity继承使用的关键字是is(类似于Java等语言的extends或implement)。

当一个合约从多个合约继承时,在区块链上只有一个合约被创建。所有基类合约的代码都被编译到创建的合约中,但是注意,这并不会连带部署基类合约。因此当我们使用super.f()来调用基类的方法时,不是进行消息调用,而仅仅是代码跳转。派生合约可以访问基类合约内的所有非私有(private)成员,因此内部(internal)函数和状态变量在派生合约里是可以直接使用的。

多重继承

Solidity也支持多重继承,即可以从多个基类合约继承,直接在is后面接多个基类合约即可,中间用,(逗号)分开。

contract Named is Owned,Mortal{
	...
}

注意:

如果是多个基类合约之间也有继承关系,那么is后面的合约书写顺序就很重要。顺序应该是,基类合约在前面,派生合约在后面,否则无法编译。

基类构造函数

派生合约继承基类合约时,如果实现了构造函数,基类合约的代码会被编译器拷贝到派生合约的构造函数中。

  • 构造函数无参数
contract A{
    uint public a;
    constructor() public{
        a = 1;
    }
}

contract B is A{
    uint public b;
    constructor() public{
        b = 2;
    }
}

在部署B的时候,可以查到a为1,b为2

  • 构造函数有参数
  1. 直接在继承列表中指定参数
contract A{
    uint public a;
    constructor(uint _a) internal{
        a = _a;
    }
}

contract B is A(1){
    uint public b;
    constructor() public{
        b = 2;
    }
}

即通过contract B is A(1)的方式对构造函数传参进行初始化。

  1. 通过派生合约的构造函数中使用修饰符方式调用基类合约
# 方式一:
contract B is A{
    uint public b;
    constructor() A(1) public{
        b = 2;
    }
}

# 方式二:
    constructor(uint _b) A(_b / 2) public{
        b = _b;
    }

抽象合约

如果一个合约有构造函数,且是内部(internal)函数,或者合约包含没有实现的函数,这个合约将被标记为抽象合约,使用关键字abstract,抽象合约无法成功部署,他们通常用作基类合约。

abstract contract A {
	uint public a;
	
	constructor(uint _a) internal {
		a = _a;
	}
}

抽象合约可以声明一个纯虚函数,纯虚函数没有具体实现代码的函数。其函数声明用;结尾,而不是用{}结尾。

pragma solidity ^0.8.0;

abstract contract A {
    function get () virtual public;
}

如果合约继承自抽象合约,并且没有通过重写(overriding)来实现所有未实现的函数,那么他本身就是抽象合约的,隐含了一个抽象合约的设计思路,即要求任何继承都必须实现其方法。

notes:

纯虚函数和用virtual关键字修饰的虚函数略有区别:virtual关键字只表示该函数可以被重写,virtual关键字可以修饰在除私有(private)可见性外的任何函数上,无论函数是纯虚函数还是普通函数,即便是重写的函数,也依然可以用virtual关键字修饰,表示该重写的函数可以被再次重写。

函数重写

合约中的虚函数(函数使用了virtual修饰的函数)可以在子合约重写该函数,以更改他们在父合约中的行为。重写的函数需要使用关键字override修饰。

pragma solidity ^0.8.0;


contract Base {
    function get () virtual public{}
}

contract Middle is Base{

}

contract Inherited is Middle{
    function get() public override{

    }
}

对于多重继承,如果有多个父合约有相同定义的函数,override关键字后必须指定所有的父合约名称。

pragma solidity ^0.8.0;


contract Base1 {
    function get () virtual public{}
}

contract Base2 {
    function get () virtual public{}
}

contract Middle is Base1, Base2{
    function get() public override( Base1, Base2){

    }
}

如果函数没有标记为virtual(除下面即将要讲到的接口外,因为接口里面所有的函数会自动标记为virtual),那么派生合约是不能重写来更改函数行为的。另外,private的函数是不可标记为virtual的。

如果getter函数的参数和返回值都和外部函数一致,外部函数是可以被public的状态变量重写的。但是public的状态变量不能被重写。

pragma solidity ^0.8.0;


contract A {
    function f () external pure virtual returns(uint){
        return 5;
    } 
}

contract B is A {
    uint public override f; 
}

接口

接口和抽象合约类似,与之不同的是,接口不实现任何函数,同时还有一下限制:

  1. 无法继承其他合约或者接口
  2. 无法定义构造函数
  3. 无法定义变量
  4. 无法定义结构体
  5. 无法定义枚举
pragma solidity >=0.5.0 <0.7.0;

interface IToken{
	function transfer (address recipient, uint amount) external;
}

就像继承其他合约一样,合约可以继承接口,接口中的函数会隐式地标记为virtual,意味着他们会被重写。

合约间利用接口通信:

接口除了抽象功能外,接口广泛使用于合约之间的通信,即一个合约调用另一个合约的接口;

例如: 下面的SimpleToken合约实现了上面的IToken接口:

contract SimpleToken is IToken{
	function transfer (address recipient, uint256 amount) public override{
		//do something~
	}
}

另外一个奖励合约(Award)则通过SimpleToken合约给用户发送奖金,奖金就是SimpleToken合约表示的代币,这时Award就需要与SimpleToken通信(外部函数调用)。

contract Award{
	IToken immutable token;
	//部署时传入SimpleToken合约地址
	constrcutor (IToken t) public {
		token = t
	}
	//sendBonus函数用来发送奖金,通过接口函数调用SimpleToken实现转帐
	function sendBonus(address user,uint256 amount) public {
		token.transfer(user,amount);
	}
}

开发合约的时候,总是会有一些函数经常被多个合约调用,这个时候可以把这些函数封装为一个库,库的关键字用library来定义。

pragma solidity >=0.5.0 <0.7.0;
library SafeMath{
	function add (uint a,uint b) internal pure returns (uint){
		uint c = a + b;
		requier(c > a, "SafeMath: addition overflow");
		return c;
	}
}

SafeMath库里面实现了一个加法函数add(),它可以在多个合约中复用;

import "./SafeMath.sol";
constract addTest{
	function add (uint x,uint y) public pure returns (uint){
		return SafeMath.add(x,y);
	}
}

当然我们可以在库里封装更多的函数,库是一个很好的代码复用手段。同时要注意,库仅仅是由函数构成,它没有自己的状态。库在使用中,根据场景不同,一种是嵌入引用的合约里部署(可以称为“内嵌库”),一种是单独部署(可以称为“链接库”)。

内嵌库

如果合约引用的库函数都是内部(internal)函数,那么编译器在编译合约时,会把库函数的代码嵌入到合约里,就像合约自己实现了这些函数,这时并不会单独部署,上面的Add Test合约引用SafeMath库就属于这种情况。

链接库

如果库代码内有公共(public)或外部(external)函数,库就会被单独部署,在以太坊链上有自己的地址,此时合约引用库是通过这个地址“链接”进行(在部署合约的时候,需要进行链接)。低级函数委托调用delegatescall(),合约在调用函数库时,就是采用委托调用的方式(这是底层处理方式,在编写代码时并不需要改动)。

前面提到,库是没有自己的状态的。因为在委托调用的方式下,库合约函数是在发起合约(又称为“主调合约”,即发起调用的合约)的上下文中执行的,因此库合约函数中使用的变量(如果有的话)都是来自主调合约的变量,库合约函数使用的是this也是主调合约的地址。我们也可以从另外一个角度来理解,库是单独部署的,而又会被多个合约引用(这也是库的主要功能:避免在多个合约里重复部署,以节约gas),如果库有自己的状态,那它一定会被多个调用合约修改状态,将无法保证调用库函数输出结果的确定性。

我们把前面的SafeMath库的add函数修改为外部函数:

pragma solidity >=0.5.0 <0.7.0;
library SafeMath{
	function add (uint a,uint b) external pure returns (uint){
		uint c = a + b;
		requier(c > a, "SafeMath: addition overflow");
		return c;
	}
}

AddTest代码不用做任何的修改,因为SafeMath库合约是独立部署的,AddTest合约要调用SafeMath库就必须先知道后者的地址,这就相当于AddTest合约会依赖于SafeMath库,因此在部署AddTest时会有一点不同,多了一个AddTest合约于SafeMath库建立链接的步骤:

# 下面列举了truffle的部署方法
deployer.deploy(SafrMath);
deployer.link(SafeMath,AddTest);
deployer.deploy(AddTest);

Using for

除了使用上面的SafeMath.add(x,y)这种方式来调用库函数,还有一个使用方式是使用using LibA for B。它表示把所有LibA的库函数关联到类型B,这样就可以在B类型直接调用库函数。

contract testLib{
	using SafeMath for uint;
	function add (uint x,uint y) public pure returns (uint){
		return x.add(y);
	}
}

using LibA for * 表示LibA中的函数可以关联到任意的类型上。使用using … for …像是拓展了类型的能力。

应用程序二进制接口(ABI)

在以太坊(Ethereum)生态系统中,应用程序二进制接口(Application Binary Interface,ABI)是从区块链外部与合约进行交互,以及合约与合约之间进行交互的一种标准方式。

以太坊和比特币交易的不同是,以太坊交易多了一个data字段,data的内容会解析为对函数的消息调用,data的内容其实就是ABI编码。

函数选择器

在函数调用时,前面四个字节的函数选择器指定要调用的函数,函数选择器是某个函数签名Keccak(SHA-3)哈希的前4字节,即:

bytes4(keccak256("count()"));

函数签名是包含函数名以及参数类型的字符串,比如上面的count()就是函数签名。当函数有参数时,使用参数的基本类型,并且不需要变量名,因此函数add(uint i)的签名是add(uint256),如果有多个参数,使用,隔开,并且要去掉表达式中的所有空格。

例如:foo(uint a, bool b)的函数签名就是foo(uint256,bool),函数选择器计算则是

bytes4(keccak256("foo(uint256,bool)"))

公有或外部(public/external)函数都有成员属性.selector来获取函数的函数选择器。

参数编码

如果函数带有参数,编码的第5个字节开始是函数的参数。

// contracts/MultiSend.sol
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

contract Counter{
    uint counter;

    constructor() {
        counter = 0;
    }

    function count () public {
        counter += 1;
    }

    function add (uint i) public {
        counter += i;
    }

    function get () public view returns (uint){
        return counter;
    }
}

将该合约部署后,发起一笔add(16)交易;将input参数复制出来

在这里插入图片描述

 input:0x1003e2d20000000000000000000000000000000000000000000000000000000000000010
 //参数说明:
 //0x1003e2d2为add(uint256)的函数选择器(bytes4(keccak256("add(uint256)")))
 //0000000000000000000000000000000000000000000000000000000000000010为16的二进制表示(会补充到  //32个字节的长度)

通常开发人员并不需要进行ABI编码调用函数,只需要提供ABI的接口描述JSON文件。

ABI接口描述

ABI接口描述是由编译器编译代码之后,生成的一个对合约所有接口和事件描述的JSON文件。

描述函数的JSON包含以下字段:

  • type:可取值有function、constructor、fallback,默认为function。
  • name:函数名称
  • inputs:一系列对象,每个对象包含以下属性。
    • name:参数名称。
    • type:参数的规范类型。
    • components:当type是元组(tuple)时,components列出元组中每个元素的名称(name)和类型(type)。
  • outputs:一系列类似inputs的对象,无返回值时,可以省略。
  • payable:true表示函数可以接收以太币,否则表示不能接受,默认值是false。
  • stateMutability:函数的可变性状态,可取值有:pure、view、nonpayable、payable。
  • constant:如果函数被指定为pure或view,则为true。

描述事件的JSON包含以下字段:

  • type:总是"event"。
  • name:事件名称。
  • inputs:对象数组,每个数组对象包含以下属性。
    • name:参数名称。
    • type:参数的权威类型。
    • components:供元组(tuple)类型使用。
  • indexed:如果此字段是日志的一个主题,则为true,否则为false。
  • anonymous:如果事件被声明为anonymous,则为true。

Solidity全局API

  • .balance:获取地址的余额。
  • .transfer():向一个地址转账。
  • require():用来检查输入变量或者合约状态变量是否满足条件,以及验证调用外部合约的返回值。
  • asset():用来检查(测试)内部错误,发生了错误说明程序出现了bug。
  • revert():用来标记错误并恢复当前调用。

区块和交易属性API

  • blockhash(uint blockNumber) returns (bytes32):获得指定区块的区块哈希,参数blockNumber仅支持传入最新的256个区块,且不包括当前区块(备注:returns后面表示的是函数返回的类型,下同)。
  • block.coinbase(address):获得挖出当前区块的矿工地址(备注:()内表示获取属性的类型,下同)
  • block.difficulty(uint):获得当前区块难度。
  • block.gaslimit(uint):获得当前区块最大gas限值。
  • block.number(uint):获得当前区块号。
  • block.timestamp(uint):获得当前区块以秒为单位的时间戳。
  • gasleft() returns (uint256):获得当前执行还剩余多少gas。
  • msg.data(bytes):获取当前调用完整的calldata参数数据。
  • msg.sender(address):当前调用的消息发送者。
  • msg.sig(bytes4):当前调用函数的标识符。
  • msg.value(uint):当前调用发送的以太币数量(以wei为单位)。
  • tx.gasprice(uint):获得当前交易的gas价格。
  • tx.origin(address payable):获得交易的起始发起者,如果交易只有当前一个调用,那么tx.origin会和msg.sender相等,如果交易中触发了多个子调用,msg.sender会是每个发起子调用的合约地址,而tx.origin依旧是发起交易的签名者。

ABI编码及解码函数API

  • abi.decode(bytes memory encodeData, (…)) returns (…):对给定的数据进行ABI解码,而数据的类型在括号中第二个参数中给出。例如:(uint a, uint[2] memory b, bytes memory c ) = abi.decode(data,(uint,uint[2],bytes))是从data数据中解码出3个变量a、b、c。
  • abi.encode(…) returns (bytes):对给定的参数进行ABI编码,即上一个方法的反向操作。
  • abi.encodePacked(…) returns (bytes):对给定参数执行ABI编码,和上一个函数编码时会把参数填充到32个字节长度不同,encodePacked编码的参数数据会紧密地拼在一起。
  • abi.encodeWithSelector(bytes4 selector,…) returns (bytes):从第二个参数开始进行ABI编码,并在前面加上给定的函数选择器(参数)一起返回。
  • abi.encodeWithSignature(string signature,…) returns (bytes)等价于abi.encodeWithSelector(bytes4(keccak256(signture)),…) returns (bytes)。

数学和密码学函数API

  • addmod(uint x, uint y, uint k) returns (uint):计算(x + y)% k,即先求和再求模。求和可以在任意精度下执行,即求和的结果可以超过uint的最大值(2的256次方)。求模运算会对k != 0做校验。
  • mulmod(uint x,uint y, uint k) returns (uint):计算(x * y)% k,即先做乘法再求模,乘法可在任意精度下执行,即乘法的结果可以超过uint的最大值。求模运算会对k != 0做校验。
  • keccak256(bytes memory) returns (bytes32):用keccak-256算法计算hash。
  • sha256(bytes memory) returns (bytes32):计算参数的SHA-256哈希。
  • ripemd160(bytes memory) returns(bytes20):计算参数的RIPEMD-160哈希。
  • ecrecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) returns (address):利用椭圆曲线签名恢复与公钥相关的地址(即通过签名数据获得地址),错误返回零值。函数参数对应于ECDSA签名的值:
    • r = 签名前的32字节
    • s = 签名的第2个32字节
      s))是从data数据中解码出3个变量a、b、c。
  • abi.encode(…) returns (bytes):对给定的参数进行ABI编码,即上一个方法的反向操作。
  • abi.encodePacked(…) returns (bytes):对给定参数执行ABI编码,和上一个函数编码时会把参数填充到32个字节长度不同,encodePacked编码的参数数据会紧密地拼在一起。
  • abi.encodeWithSelector(bytes4 selector,…) returns (bytes):从第二个参数开始进行ABI编码,并在前面加上给定的函数选择器(参数)一起返回。
  • abi.encodeWithSignature(string signature,…) returns (bytes)等价于abi.encodeWithSelector(bytes4(keccak256(signture)),…) returns (bytes)。

数学和密码学函数API

  • addmod(uint x, uint y, uint k) returns (uint):计算(x + y)% k,即先求和再求模。求和可以在任意精度下执行,即求和的结果可以超过uint的最大值(2的256次方)。求模运算会对k != 0做校验。
  • mulmod(uint x,uint y, uint k) returns (uint):计算(x * y)% k,即先做乘法再求模,乘法可在任意精度下执行,即乘法的结果可以超过uint的最大值。求模运算会对k != 0做校验。
  • keccak256(bytes memory) returns (bytes32):用keccak-256算法计算hash。
  • sha256(bytes memory) returns (bytes32):计算参数的SHA-256哈希。
  • ripemd160(bytes memory) returns(bytes20):计算参数的RIPEMD-160哈希。
  • ecrecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) returns (address):利用椭圆曲线签名恢复与公钥相关的地址(即通过签名数据获得地址),错误返回零值。函数参数对应于ECDSA签名的值:
    • r = 签名前的32字节
    • s = 签名的第2个32字节
    • v = 签名的最后一个字节

猜你喜欢

转载自blog.csdn.net/weixin_45340300/article/details/125570404