Solidity 其它高级特性

包括库、附着库、状态变量的存储模型、内存变量的布局等

库(Libraries)

库与合约类似,但它的目的是在一个指定的地址,且仅部署一次,然后通过EVM的特性DELEGATECALL(Homestead之前是用CALLCODE)来复用代码。这意味着库函数调用时,它的代码是在调用合约的上下文中执行。使用this将会指向到调用合约,而且可以访问调用合约的存储(storage)。因为一个合约是一个独立的代码块,它仅可以访问调用合约明确提供的状态变量(state variables),否则除此之外,没有任何方法去知道这些状态变量。

使用库合约的合约,可以将库合约视为隐式的父合约(base contracts),当然它们不会显式的出现在继承关系中。但调用库函数的方式非常类似,如库L有函数f(),使用L.f()即可访问。此外,internal的库函数对所有合约可见,如果把库想像成一个父合约就能说得通了。当然调用内部函数使用的是internal的调用惯例,这意味着所有internal类型可以传进去,memory类型则通过引用传递,而不是拷贝的方式。为了在EVM中实现这一点,internal的库函数的代码和从其中调用的所有函数将被拉取(pull into)到调用合约中,然后执行一个普通的JUMP来代替DELEGATECALL。

下面的例子展示了如何使用库(后续在using for章节有一个更适合的实现Set的例子)。

pragma solidity ^0.4.0;
library Set {
    // We define a new struct datatype that will be used to
    // hold its data in the calling contract.
    struct Data { mapping(uint => bool) flags; }
    // Note that the first parameter is of type "storage
    // reference" and thus only its storage address and not
    // its contents is passed as part of the call.  This is a
    // special feature of library functions.  It is idiomatic
    // to call the first parameter 'self', if the function can
    // be seen as a method of that object.
    function insert(Data storage self, uint value)
        returns (bool)
    {
        if (self.flags[value])
            return false; // already there
        self.flags[value] = true;
        return true;
    }
    function remove(Data storage self, uint value)
        returns (bool)
    {
        if (!self.flags[value])
            return false; // not there
        self.flags[value] = false;
        return true;
    }
    function contains(Data storage self, uint value)
        returns (bool)
    {
        return self.flags[value];
    }
}
contract C {
  Set.Data knownValues;
  function register(uint value) {
    // The library functions can be called without a
    // specific instance of the library, since the
    // "instance" will be the current contract.
    if (!Set.insert(knownValues, value))
        throw;
  }
  // In this contract, we can also directly access knownValues.flags, if we want.
}

上面的例子中:

  • Library定义了一个数据结构体,用来在调用的合约中使用(库本身并未实际存储的数据)。如果函数需要操作数据,这个数据一般是通过库函数的第一个参数传入,按惯例会把参数名定为self。
  • 另外一个需要留意的是上例中self的类型是storage,那么意味着传入的会是一个引用,而不是拷贝的值,那么修改它的值,会同步影响到其它地方,俗称引用传递,非值传递。
  • 库函数的使用不需要实例化,c.register中可以看出是直接使用Set.insert。但实际上当前的这个合约本身就是它的一个实例。 这个例子中,c可以直接访问,knownValues。虽然这个值主要是被库函数使用的。
  • 当然,你完全可以不按上面的方式来使用库函数,可以不需要定义结构体,不需要使用storage类型的参数,还可以在任何位置有多个storage的引用类型的参数。

调用Set.contains,Set.remove,Set.insert都会编译为以DELEGATECALL的方式调用external的合约和库。如果使用库,需要注意的是一个实实在在的外部函数调用发生了。尽管msg.sender,msg.value,this还会保持它们在此调用中的值(在Homestead之前,由于实际使用的是CALLCODE,msg.sender,msg.value会变化)。

下面的例子演示了如何使用memory类型和内部函数(inernal function),来实现一个自定义类型,但不会用到外部函数调用(external function)。

pragma solidity ^0.4.0;
library BigInt {
    struct bigint {
        uint[] limbs;
    }
    function fromUint(uint x) internal returns (bigint r) {
        r.limbs = new uint[](1);
        r.limbs[0] = x;
    }
    function add(bigint _a, bigint _b) internal returns (bigint r) {
        r.limbs = new uint[](max(_a.limbs.length, _b.limbs.length));
        uint carry = 0;
        for (uint i = 0; i < r.limbs.length; ++i) {
            uint a = limb(_a, i);
            uint b = limb(_b, i);
            r.limbs[i] = a + b + carry;
            if (a + b < a || (a + b == uint(-1) && carry > 0))
                carry = 1;
            else
                carry = 0;
        }
        if (carry > 0) {
            // too bad, we have to add a limb
            uint[] memory newLimbs = new uint[](r.limbs.length + 1);
            for (i = 0; i < r.limbs.length; ++i)
                newLimbs[i] = r.limbs[i];
            newLimbs[i] = carry;
            r.limbs = newLimbs;
        }
    }
    function limb(bigint _a, uint _limb) internal returns (uint) {
        return _limb < _a.limbs.length ? _a.limbs[_limb] : 0;
    }
    function max(uint a, uint b) private returns (uint) {
        return a > b ? a : b;
    }
}
contract C {
    using BigInt for BigInt.bigint;
    function f() {
        var x = BigInt.fromUint(7);
        var y = BigInt.fromUint(uint(-1));
        var z = x.add(y);
    }
}

因为编译器并不知道库最终部署的地址。这些地址须由linker填进最终的字节码中(使用命令行编译器来进行联接)。如果地址没有以参数的方式正确给到编译器,编译后的字节码将会仍包含一个这样格式的占们符_Set___(其中Set是库的名称)。可以通过手动将所有的40个符号替换为库的十六进制地址。

对比普通合约来说,库的限制:

  • 无状态变量(state variables)。
  • 不能继承或被继承
  • 不能接收ether。
  • 这些限制将来也可能被解除!

附着库(Using for)

指令using A for B;用来附着库里定义的函数(从库A)到任意类型B。这些函数将会默认接收调用函数对象的实例作为第一个参数。语法类似,python中的self变量一样。

using A for *的效果是,库A中的函数被附着在做任意的类型上。

在这两种情形中,所有函数,即使那些第一个参数的类型与调用函数的对象类型不匹配的,也被附着上了。类型检查是在函数被真正调用时,函数重载检查也会执行。

using A for B;指令仅在当前的作用域有效,且暂时仅仅支持当前的合约这个作用域,后续也非常有可能解除这个限制,允许作用到全局范围。如果能作用到全局范围,通过引入一些模块(module),数据类型将能通过库函数扩展功能,而不需要每个地方都得写一遍类似的代码了。

下面我们来换个方式重写set的例子。

pragma solidity ^0.4.0;
// This is the same code as before, just without comments
library Set {
struct Data { mapping(uint => bool) flags; }
function insert(Data storage self, uint value)
    returns (bool)
{
    if (self.flags[value])
        return false; // already there
    self.flags[value] = true;
    return true;
}
function remove(Data storage self, uint value)
    returns (bool)
{
    if (!self.flags[value])
        return false; // not there
    self.flags[value] = false;
    return true;
}
function contains(Data storage self, uint value)
    returns (bool)
{
    return self.flags[value];
}
}
contract C {
    using Set for Set.Data; // this is the crucial change
    Set.Data knownValues;
    function register(uint value) {
        // Here, all variables of type Set.Data have
        // corresponding member functions.
        // The following function call is identical to
        // Set.insert(knownValues, value)
        if (!knownValues.insert(value))
            throw;
    }
}

我们也可以通过这种方式来扩展基本类型(elementary types)。

pragma solidity ^0.4.0;
library Search {
    function indexOf(uint[] storage self, uint value) returns (uint) {
        for (uint i = 0; i < self.length; i++)
            if (self[i] == value) return i;
        return uint(-1);
    }
}
contract C {
    using Search for uint[];
    uint[] data;
    function append(uint value) {
        data.push(value);
    }
    function replace(uint _old, uint _new) {
        // This performs the library function call
        uint index = data.indexOf(_old);
        if (index == uint(-1))
            data.push(_new);
        else
            data[index] = _new;
    }
}

需要注意的是所有库调用都实际上是EVM函数调用。这意味着,如果你传的是memory类型的,或者是值类型(vaue types),那么仅会传一份拷贝,即使是self变量。变通之法就是使用存储(storage)类型的变量,这样就不会拷贝内容。

状态变量的存储模型(Layout of State Variables in Storage)

大小固定的变量(除了映射,变长数组以外的所有类型)在存储(storage)中是依次连续从位置0开始排列的。如果多个变量占用的大小少于32字节,会尽可能的打包到单个storage槽位里,具体规则如下:

  • 在storage槽中第一项是按低位对齐存储(lower-order aligned)(译者注:意味着是大端序了,因为是按书写顺序。)。
  • 基本类型存储时仅占用其实际需要的字节。
  • 如果基本类型不能放入某个槽位余下的空间,它将被放入下一个槽位。
  • 结构体和数组总是使用一个全新的槽位,并占用整个槽(但在结构体内或数组内的每个项仍遵从上述规则)

优化建议

当使用的元素占用少于32字节,你的合约的gas使用也许更高。这是因为EVM每次操作32字节。因此,如果元素比这小,EVM需要更多操作来从32字节减少到需要的大小。

因为编译器会将多个元素打包到一个storage槽位,这样就可以将多次读或写组合进一次操作中,只有在这时,通过缩减变量大小来优化存储结构才有意义。当操作函数参数和memory的变量时,因为编译器不会这样优化,所以没有上述的意义。

最后,为了方便EVM进行优化,尝试有意识排序storage的变量和结构体的成员,从而让他们能打包得更紧密。比如,按这样的顺序定义,uint128, uint128, uint256,而不是uint128, uint256, uint128。因为后一种会占用三个槽位。

非固定大小

结构体和数组里的元素按它们给定的顺序存储。

由于它们不可预知的大小。映射和变长数组类型,使用Keccak-256哈希运算来找真正数据存储的起始位置。这些起始位置往往是完整的堆栈槽。

映射和动态数组根据上述规则在位置p占用一个未满的槽位(对映射里嵌套映射,数组中嵌套数组的情况则递归应用上述规则)。对一个动态数组,位置p这个槽位存储数组的元素个数(字节数组和字符串例外,见下文)。而对于映射,这个槽位没有填充任何数据(但这是必要的,因为两个挨着的映射将会得到不同的哈希值)。数组的原始数据位置是keccak256(p);而映射类型的某个键k,它的数据存储位置则是keccak256(k . p),其中的.表示连接符号。如果定位到的值以是一个非基本类型,则继续运用上述规则,是基于keccak256(k . p)的新的偏移offset。

bytes和string占用的字节大小如果足够小,会把其自身长度和原始数据存在当前的槽位。具体来说,如果数据最多31位长,高位存数据(左对齐),低位存储长度lenght * 2。如果再长一点,主槽位就只存lenght * 2 + 1。原始数据按普通规则存储在keccak256(slot)

所以对于接下来的代码片段:

pragma solidity ^0.4.4;
contract C {
    struct s { uint a; uint b; }
    uint x;
    mapping(uint => mapping(uint => s)) data;
}

按上面的代码来看,结构体从位置0开始,这里定义了一个结构体,但并没有对应的结构体变量,故不占用空间。uint x实际是uint256,单个占32字节,占用槽位0,所以映射data将从槽位1开始。

data[4][9].b的位置在keccak256(uint256(9) . keccak256(uint256(4) . uint256(1))) + 1

有人在这里尝试直接读取区块链的存储值这里

内存变量的布局(Layout in Memory)

Solidity预留了3个32字节大小的槽位:

  • 0-64:哈希方法的暂存空间(scratch space)
  • 64-96:当前已分配内存大小(也称空闲内存指针(free memory pointer))

暂存空间可在语句之间使用(如在内联编译时使用)

Solidity总是在空闲内存指针所在位置创建一个新对象,且对应的内存永远不会被释放(也许未来会改变这种做法)。

有一些在Solidity中的操作需要超过64字节的临时空间,这样就会超过预留的暂存空间。他们就将会分配到空闲内存指针所在的地方,但由于他们自身的特点,生命周期相对较短,且指针本身不能更新,内存也许会,也许不会被清零(zerod out)。因此,大家不应该认为空闲的内存一定已经是清零(zeroed out)的。

内部机制

内部机制 - 清理变量(Internals - Cleaning Up Variables)

当一个值占用的位数小于32字节时,那些没有用到的位必须被清除掉。Solidity编译器设计实现为,在任何可能受到潜在的残存数据带来的副作用之前,清理掉这些脏数据。比如,在向内存写入一个值前,不需要的字节位需要被清除掉,因为没有用到的内存位可能被用来计算哈希,或作为消息调用的发送的数据存储。同样的,在向storage中存储时,未用到的字节位需要被清理掉,否则这些脏数据会带来意想不到的事情。

另一方面,如果接下来的后述操作不会产生副作用,我们不会主动清理这些字节位。比如,由于任何非0的值被JUMP指令认为是true。在它作用JUMPI指令的条件前,我们在不会清理这个布尔值。

在上述设计准则之外,Solidity编译器会在输入数据加载到栈上后清理掉它们。

不同的类型,有不同的无效值的清理规则。

| Tables | Are | Cool |

| ---------- | :-------------: | ------: |

| col 1 is | left-aligned | $1600 |

| col 2 is | centered | $12 |

| col 3 is | right-aligned | $1 |

| 类型 | 有效值 | 无效值意味着 |

| --------------------- | :------------------: | -----------------------------------------------: |

| 有n的成员的枚举类型 | 0到(n - 1) | 异常(exception) |

| 布尔 | 0或1 | 1 |

| 有符号整数 | sign-extended word | 当前静默的包装了结果,以后会以异常的形式抛出来 |

| 无符号整数 | 高位节是0 | 当前静默的包装了结果,以后会以异常的形式抛出来 |

内部机制 - 优化(Internals - The Optimizer)

Solidity是基于汇编优化的,所以它可以,同时也被其它编程语言所使用(译者注:其它语言编译为汇编)。编译器会在JUMP和JUMPDEST处拆分基本的指令序列为一个个的基本块。在这些代码块内,所有的指令都被分析。所有的对栈,内存或存储的操作被记录成由指令及其参数组成的一个个表达式,这些表达式又会指向另一个表达式。核心目的是找到一些表达式在任何输入的情况下都恒等,然后将它们组合成一个表达式类。优化器首先尝试在一系列已知的表达式中,找出来一些全新的表达式。如果找不到,表达式通过一些简单的原则进行简化,比如 constant + constant = sum_of_constants 或 X * 1 = X。由于这一切是递归进行的,我们可以在第二项是一个更复杂的表达时,应用上述后续规则。对内存或存储的修改,存储的位置经常会被擦除,由此我们并不知道存的数据有什么不同:如果我们首先写入一个值x,再写入另一个值y,这两个都是输入变量,第二个写入时会覆盖第一个,所以我们实际在写入第二个值时,不知道第一个值是什么了。所以,如果一个简单的表达式x-y指向一个非0的常量,这样我们就能在操作y时知道x内存储的值。

在流程最后,我们知道哪一个表达式会在栈顶,并且有一系列的对内存或存储的修改。这些信息与基本的块存在一起以方便的用来连接他们。此外,关于栈,存储和内存配置的信息会传递到下一个块。如果我们知道所有JUMP和JUMPI指令的目标,我们可以构建程序的完整的控制流程图。如果有任何一个我们不知道目标的跳转(因为目标是通过输入参数进行计算的,所以原则上可能发生),我们必须擦除块知识的输入,因为他有可能是某个跳转的目的地(译者注:因为可能某个跳转在运行时会指向他,修改他的状态,所以他的推算状态是错误的)。如果某个JUMPI被发现他的条件是常量,它会被转化为一个无状态的跳转。

在最后一步,每个块中的代码都将重新生成。在某个块结束时,将生成栈上表达式的依赖树,不在这个树上的操作就被丢弃了。在我们原始代码中想要应用的对内存、存储想要的修改顺序的代码就生成出来了(被丢弃的修改被认为是完全不需要的),最终,生成了所有的需要在栈上存在的值。

这些步骤应用于每个基本的块,如果新生成的代码更小,将会替换现有的代码。如果一个块在分析期间在JUMPI处分裂,条件被证实为一个常量,JUMPI将可以基于常量值被替换掉,比如下述代码:

var x = 7;
data[7] = 9;
if (data[x] != x + 2)
    return 2;
else
    return 1;

简化的代码可以被编译为:

data[7] = 9;
return 1;

尽管上述代码在一开始有一个跳转指令。

特殊特性(Esoteric Features)

在Solidity的类型系统里面有一些类型有一些在其它语言中没有的语法。其中之一就是函数类型。但依然,使用var时,可以把函数类型作为本地变量。

contract FunctionSelector {
    function select(bool useB, uint x) returns (uint z) {
        var f = a;
        if (useB) f = b;
        return f(x);
    }
    function a(uint x) returns (uint z) {
        return x * x;
    }
    function b(uint x) returns (uint z) {
        return 2 * x;
    }
}

可以对var赋值为不同的函数。