区块链

20180925_合约总结(Solidity)

2018-10-18  本文已影响66人  mingmingz

一.ABI

ABI是和Ethereum生态系统的合约进行交互的标准方式,所有合约的调用都是通过ABI.一个函数调用时的call data中前四个bytes指定了哪个函数被调用,从第五个字节开始则是函数的编码.

function selector

所有合约交易的function selector都是根据methodName(type01,type02,...typeN)的keccak256前八位来决定的,例如例子的Erc20规范转账,前四个bytes一定是a9059cbb

String encF = Hash.sha3String("transfer(address,uint256)");
//0xa9059cbb2ab09eb219583f4a59a5d0623ade346d962bcd4e46b11da047c9049b

arugment encoding

不同类型有不同的编码规则,定长类型和不定长类型的编码规则也是不同的.

这些是不定长的类型:

其他类型都是定长的.

example

给定一个合约:

pragma solidity ^0.4.16;

contract Foo {
  function bar(bytes3[2]) public pure {}
  function baz(uint32 x, bool y) public pure returns (bool r) { r = x > 32 || y; }
  function sam(bytes, bool, uint[]) public pure {}
}
function baz(uint32 x, bool y)

因此,对于我们的合约Foo,如果我们想传入69true来调用函数baz,我们总共会传入68个bytes,拆开来看:

拼一起:

0xcdcd77c000000000000000000000000000000000000000000000000000000000000000450000000000000000000000000000000000000000000000000000000000000001

这个函数返回单一参数bool.如果它返回了false,它的输出会是一个单一的byte数组0x0000000000000000000000000000000000000000000000000000000000000000,一个bool.

function bar(bytes3[2])

如果我们想传入["abc","def"]来调用bar,我们一共会传入68bytes,细分如下:

拼一起:

0xfce353f661626300000000000000000000000000000000000000000000000000000000006465660000000000000000000000000000000000000000000000000000000000
function sam(bytes, bool, uint[])

如果我们想传入参数 "dave", true[1,2,3]来调用 sam, 我们总共会传入292 bytes, 细分如下:

拼一起:

0xa5643bf20000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000464617665000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000003

dynamic types

调用一个有签名的函数f(uint,uint32[],bytes10,bytes)传入值(0x123,[0x456,0x789],"1234567890","Hello, world!")会以下面这种方式编码:

调用一个有签名的函数f(uint,uint32[],bytes10,bytes)传入值(0x123,[0x456,0x789],"1234567890","Hello, world!")会以下面这种方式编码:

先获取sha3("f(uint256,uint32[],bytes10,bytes)")的前四个字节,我们拿到0x8be65246.然后我们对四个参数的头部部分进行编码.对于静态类型uint256bytes10,他们会直接将他们自己穿进去,而对于动态类型uint32[]bytes来说,我们将它们的数据区域的开始部分以字节为单位进行偏移,从编码后的位置开始数(例如:函数签名的前四个字节不纳入位置).将会编码成:

在这之后,第一个动态参数的数据部分[0x456,0x789]如下所示:

最后,我们将第二个动态参数的数据"Hello, world!"编码出来:

全部放在一起, (0x123,[0x456,0x789],"1234567890","Hello, world!")编码出来的内容是 (为了易读性,函数选择器后每32-bytes都会另起一行):

0x8be65246 //函数选择器 (Method ID)
  0000000000000000000000000000000000000000000000000000000000000123 //uint 的0x123
  0000000000000000000000000000000000000000000000000000000000000080 //uint32[] 的内容偏移位置
  3132333435363738393000000000000000000000000000000000000000000000 //string的utf-8转hex
  00000000000000000000000000000000000000000000000000000000000000e0 //bytes 的偏移位置
  0000000000000000000000000000000000000000000000000000000000000002 //第二个参数(数组)的长度
  0000000000000000000000000000000000000000000000000000000000000456 //第二个参数的第一个元素
  0000000000000000000000000000000000000000000000000000000000000789 //第二个参数的第二个元素
  000000000000000000000000000000000000000000000000000000000000000d //第四个参数的长度
  48656c6c6f2c20776f726c642100000000000000000000000000000000000000 //第四个参数的bytes转hex

一个函数中如果包含定长类型和动长类型,ABI的生成总结下来就是:

  1. method ID
  2. 依据参数顺序依次进行编码,如果是定长类型,直接encode,如果是动长类型,存入其开始的偏移位置
  3. 依次展示动长类型的长度以及他们的数据编码

events

编码规则和function ABI类似,会由以下部分组成:

例如一个标准Erc20转账的交易:

Function: transfer(address _to, uint256 _value)

MethodID: 0xa9059cbb
[0]:  000000000000000000000000320bc367d5c1ebe5695f8f2544db46d990d5241c
[1]:  000000000000000000000000000000000000000000000002a802f8630a240000

会生成以下Event:


Address     0xc3af5103551287cfc8f12d7bfe208e0c2c3c3ff1  
Name        Transfer (index_topic_1 address from, index_topic_2 address to, uint256 value)
Topics
[0]         0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
[1]         0x0000000000000000000000007ab2b50a62006c0e87b527228761b8501c798c6e
[2]         0x000000000000000000000000320bc367d5c1ebe5695f8f2544db46d990d5241c
   Data 
            000000000000000000000000000000000000000000000002a802f8630a240000

对于event和function来说,他们的ABI都会将省略类型补成官方定义的类型

二.storage or memory

官方定义中EVM有三个可以存储的区域:

第一个是"storage",其中包含所有合同状态变量。每个合同都有自己的存储空间,它在函数调用之间是持久的,使用起来非常昂贵。
第二个是"memory",这用于保存临时值。它在(外部)函数调用之间被擦除,并且使用起来更便宜。
第三个是"stack",用于保存小的局部变量。它几乎可以免费使用,但只能保留有限数量的值。
最重要的是,如果您例如在函数调用中传递此类变量,则如果它们可以保留在memory中或保留在storage中,则不会复制它们的数据。
  1. storagememory关键字用于在存储和存储器分别引用数据。
  2. 合同存储在合同构建期间预先分配,不能在函数调用中创建。毕竟,如果要保留函数,在函数存储中创建新变量没有多大意义。
  3. 在合同构建期间不能分配内存,而是在函数执行中创建。合同状态变量始终在存储中声明。同样,拥有不能持久的状态变量也没有意义。
  4. memory引用数据分配给storage引用变量时,我们将数据从内存复制到存储。没有创建新存储。
  5. storage引用数据分配给memory引用变量时,我们将数据从存储复制到内存。分配了新内存。
  6. storage在函数通过查找创建本地变量,它简单地引用已经分配上存储数据。没有创建新存储。

以下面的代码作为示例,先看看getter:

// 不设置成view函数来估算gas
function getUsingStorage(uint _itemIdx) public returns (uint){
    Item storage item = items[_itemIdx];
    return item.units;
}
// 不设置成view函数来估算gas
function getUsingMemory(uint _itemIdx) public returns (uint){
    Item memory item = items[_itemIdx];
    return item.units;
}

两个函数都返回相同的结果,但是getUsingMemory创建一个新变量所以使用了更多的gas :

// getUsingStorage
"gasUsed": 21849
// getUsingMemory
"gasUsed": 22149

另外我们再看看setter:

function addItemUsingStorage(uint _itemIdx, uint _units) public{
    Item storage item = items[_itemIdx];
    item.units += _units;
}

function addItemUsingMemory(uint _itemIdx, uint _units) public{
    Item memory item = items[_itemIdx];
    item.units += _units;
}

只有addItemUsingStorage 修改了状态变量(意味着消费了更多gas):

// addItemUsingStorage
// `units` changes in `items`
"gasUsed": 27053,
// addItemUsingMemory
// `units` does not change in `items`
"gasUsed": 22287

作为结论:

  1. memorystorage指定data location变量引用的内容
  2. storage不能在函数中新创建。storage函数中任何引用的变量总是指在合同存储(状态变量)上预先分配的一段数据。函数调用后任何突变都会持续存在。
  3. memory只能在函数中新创建。它可以是新实例化的复杂类型,如array / struct(例如通过new int[...]),也可以从storage引用的变量中复制。
  4. 由于引用是通过函数参数在内部传递的,请记住它们默认是memory,如果变量是在 storage中,它将创建一个副本,任何修改都不会持久。(这条有问题吧)

参考

三.细节及技巧

命名勿用关键字

除了目前已经存在的关键字(函数声明,事件声明,变量声明的关键字)以外,以下关键字也请保留

abstract, after, case, catch, default, final, in, inline, let, match, null, of, relocatable, static, switch, try, type, typeof.

storage中变量的存储位置

当使用小于32 bytes的元素时,你合约用的gas可能会更高.这是因为EVM每次操作都是使用32bytes.因此,如果一个元素比32 bytes小,EVM必须使用更多的操作去减小这个元素的大小到它需要的大小.

最后,EVM存储中有一个概念叫存储槽,为了让EVM去优化,保证你尝试去给你的存储变量以及struct的成员变量排序,以让他们能被紧紧地打包.例如:将你声明的uint128,uint256, uint128替换为 uint128, uint128, uint256,前者会使用3个存储槽而后者只会使用2个.

错误处理(作为事务)

为了让合约函数的操作不影响state variables,我们需要考虑到revert.

在函数开始时就需要进行判断条件并revert的,能使用require就使用它(如果是经常需要判断的情况,请抽取到modifier中).

在函数执行时需要根据条件进行revert的,使用assert而不是if,assert能直接帮我们使事务无效,并进行revert.

如果有异常发生,assert会消耗当前调用函数的所有gas,而require从Metropolis版本开始不会消耗任何gas

变量声明

默认值

所有声明出来的变量都有一个初始默认值,是它们这个类型对应的bytes32的0,例如:bool的默认值是false,uintint的默认值是0.

对于静态长度的数组以及从byte1byte32这些类型来说,它们存储的每个元素的默认值都对应它们类型的0.

对于一个动态长度的数组,bytes以及string来说,默认值是空数组(长度为0的数组)或空字符串.

范围

Solidity继承了JS的范围规则—变量声明后它就属于整个函数而不是当前代码块.例如:

pragma solidity ^0.4.16;

contract ScopingErrors {
    function scoping() public {
        uint i = 0;

        while (i++ < 1) {
            uint same1 = 0;
        }

        while (i++ < 2) {
            uint same1 = 0;// 非法, 第二次声明 same1
        }
    }

    function minimalScoping() public {
        {
            uint same2 = 0;
        }

        {
            uint same2 = 0;// 非法, 第二次声明 same2
        }
    }

    function forLoopScoping() public {
        for (uint same3 = 0; same3 < 1; same3++) {
        }

        for (uint same3 = 0; same3 < 1; same3++) {// 非法, 第二次声明 same3
        }
    }
}

但是,从v0.5.0开始,Solidty的变量范围会更改成遵循C99标准,变量的可见性在它声明出来直到{}代码块结束.作为此规则的一个例外,在for循环的初始化部分中声明的变量只有在for循环结束前可见(和大多数语言一致).

下面这个例子不会产生任何警告,因为这两个变量虽然有同样的名字但是范围不相交.在非0.5.0的模式,他们有同样的scope(在function minimalScoping中)并且不会编译通过.

pragma solidity ^0.4.0;
pragma experimental "v0.5.0";
contract C {
    function minimalScoping() pure public {
        {
            uint same2 = 0;
        }

        {
            uint same2 = 0;
        }
    }
}

Getter

所有state variables声明为public时,编译器都会为他们自动生成getter函数,该函数与state variables的名称一致.

对于普通类型(非数组,非mapping)的元素来说,如果声明成public,直接调用与variables名称相同的函数即可获取它的值,

对于数组来说,会生成variableName(uint index)的函数来获取对应角标的值:

例如:

pragma solidity ^0.4.0;
contract Getter{
    uint public x =20;
    uint[] public arr = [10,20,30];
    mapping (uint => uint) public map;
    
    constructor() public{
        map[3] = 13;
        map[4] = 14;
    }
}
//编译器自动生成的getter

function x() view public{
    return x; 
}

function arr(uint index) view public returns (uint){
    return arr[index];
}

function map(uint key) view public returns (uint){
    return map[key];
}

再看编译器会如何为一个复杂结构的变量生成getter:

pragma solidity ^0.4.0;

contract Complex {
    struct Data {
        uint a;
        bytes3 b;
        mapping (uint => uint) map;
    }
    mapping (uint => mapping(bool => Data[])) public datas;
}

//例如上面的public datas,会自动生成如下的getter:
function datas(uint arg1, bool arg2, uint arg3) public returns (uint a, bytes3 b) {
    a = datas[arg1][arg2][arg3].a;
    b = datas[arg1][arg2][arg3].b;
}

提供Fallback Function

每个合约可以有并且只有一个没有名称的函数,该函数就是Fallback Function(备用函数),它会在其他函数都匹配不上所给出的method selector时被调用(或者没有提供任何数据时).

这个函数可以用于函数接受Ether,但它如果不被标记为payable的话是无法接受Ether的,记得给每个Fallback Function提供payable标记.

函数重载

在Solidity中也有函数重载的概念(函数名称相同的参数类型不同或数量),例如:

pragma solidity ^0.4.16;

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

    function f(uint _in, bytes32 _key) public pure returns (uint out) {
        out = 2;
    }
}

如果两个外部可见函数中的Solidity类型不同但是外部类型相同,则会有错(见下面示例:Baddress虽然在Solidity中声明出来是不同的,但实际上是一致的).

// This will not compile
pragma solidity ^0.4.16;

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的address类型,尽管他们在Solidity中是不同类型的.

注意

函数的返回参数不能作为重载的考虑因素.

主要类型相同的参数尽量不要重载,可能会引起一些不必要的错误,例如:

pragma solidity ^0.4.16;

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

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

调用f(50)会创建一个类型异常,因为50可以被隐式转换为uint8uint256.

合约继承的构造函数参数传递

所有基本合约的构造函数调用需要遵循以下的线性规则.如果base构造函数有参数,派生合约需要指定这些参数.能以以下两种方式来完成:

pragma solidity ^0.4.22;

contract Base {
    uint x;
    //base contract的有参构造
    constructor(uint _x) public { x = _x; }
}

//在声明合约的时候指定base contract的构造参数
contract Derived1 is Base(7) {
    constructor(uint _y) public {}
}

//在声明构造函数的时候将base contract constructor写上
contract Derived2 is Base {
    constructor(uint _y) Base(_y * _y) public {}
}

第一种方式:直接在声明继承时传入.如果构造函数的参数是常量,那么使用这种更方便.第二种方式:像modifier一样直接声明在constructor后面.如果base contract构造函数的参数依赖于derived contract构造函数的参数,那需要使用第二种.参数一定需要在继承列表或以修饰符风格写在派生构造函数后.同时在这两个地方指定参数是错误的.

如果派生合约没有将所有base contracts' constructors的参数指定出来,那么它将会是abstract的.

多继承同名错误

如果一个合约多继承中出现了function和modifier同名,这会视为是错误.这个错误同样会因为一个event和一个modifier名字一样引起,一个function和event也是不能同名的.作为一个例外,一个state variable的getter可以override一个pulic function.

Mapping

Mapping可以被看成hash tables,但EVM中的Mapping是被虚拟初始化出来的,并且拥有所有可能的key,以及这些key都映射到了其字节表示全为零的值.(ps:mapping中的key并不保存key本身,而是其keccak256的hash).因此,mapping并没有长度以及类似key/value这种形式的set.

mapping只允许存在在state variables(或者内部函数的storage引用中).

mapping是不支持遍历的,但是可以基于它创建一个可遍历的数据结构,例如 [iterable mapping](iterable mapping),另外也可以考虑自己设计一个与数组相关的数据结构来记录key以获取value.

return struct

在合约中,内部函数可以返回struct,外部函数(或public函数)不能返回struct,如果想要返回,可以返回一组值,如下面示例:

contract MyContract {
  struct MyStruct {
    string str;
    uint i;
  }

  MyStruct myStruct;

  constructor() public{
    myStruct = MyStruct("foo", 1);
  }
  
  //internal function可以返回struct
  function returnStruct() internal view returns (MyStruct){
      return myStruct;
  }
    
  //目前版本public函数不能返回struct,但是可以使用`pragma experimental ABIEncoderV2;`让编译通过
  //function returnStructPublic() public view returns (MyStruct){
  //    return myStruct;
  //}

  function myMethod() external view returns (string, uint) {
    return (myStruct.str, myStruct.i);
  }
}

改变数组长度

我们可以使用arrayName.length = uint来改变storage中一个动态数组的长度,如果改变长度的时候报了一个错误:lvalue,我们可能因为下面两个原因让这个错误出现:

1.在对memory中的array进行resize;

2.对一个非动态长度的数组进行resize;

int8[] memory memArr;       // Case 1
memArr.length++;            // illegal(memory的动态长度array不能resize)
int8[5] storageArr;         // Case 2
somearray.length++;         // legal
int8[5] storage storageArr2; // Explicit case 2
somearray2.length++;         // legal
上一篇下一篇

猜你喜欢

热点阅读