库是用于实现代码复用的,使用 library {} 进行声明。库通常用于保存内部与外部函数调用。

库很像合约,它也可以被部署到某个地址。当它被部署到某个地址时,库中的外部函数也可以被另一个合约调用。但目前库存在以下限制:

  • 不能拥有状态变量
  • 不能继承或被继承
  • 不能接受以太币
  • 不能被销毁

库中的内部函数与外部函数调用的实现,存在很大的差异,因此我们分开讲。

调用库中的内部函数

假设我们有一个数学库 Math,其中有一个内部函数 max

LibraryInternal.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;

library Math {
    // 库中的内部函数
    function max(uint a, uint b) internal pure returns (uint) {
        return a > b ? a : b;
    }
}

contract LibraryInternal {
    function test() public pure {
        Math.max(0, 1);
    }
}

当我们在合约内调用 Math.max 时,由于 max 是内部函数,因此最终 Math.max 函数的代码在编译时,会一起被添加到合约代码中去,调用 max 函数时,跟调用合约内的其他内部函数是一样的,直接通过 jump 跳转到该代码中过去。

因此,当合约被部署到以太坊区块链上时,只有合约被部署,而 Math 库是不会被部署上去的。

由于 max 函数实际上相当于直接添加到合约代码中去。因此,max 函数内,this 仍指向合约实例:

LibraryInternalThis.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;

library Math {
    // 库中的内部函数
    function max() internal view returns (address) {
        // 返回 this 的地址,在本例中,由于 max 是内部函数
        // 因此 this 即是合约实例,即 this 地址等于合约的部署地址
        return address(this);
    }
}

contract LibraryInternal {
    function getMaxThisAddress() public view returns (bool) {
        return address(this) == Math.max();
    }
}

调用 getMaxThisAddress 函数将返回 true。表明库的内部函数仍然是在 LibraryInternal 合约实例的上下文中执行的,this 指向 LibraryInternal 合约实例。

由于库的内部函数仍然是在原合约的实例上下文中执行的,因此库中的内部函数也可以直接访问和修改原合约的状态变量:

LibraryInternalStateVar.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;

library Math {
    // 库中的内部函数
    function max(int256[] storage nums) internal view returns (int256) {
        int256 result = type(int256).min;
        for (uint256 i = 0; i < nums.length; i++)
            if (result < nums[i]) result = nums[i];
        return result;
    }
}

contract LibraryInternal {
    int256[] nums;

    function getMaxNum() public returns (int256) {
        nums.push(1);
        nums.push(2);
        nums.push(1);
        // 调用 Math 库 math 内部函数,传入状态变量引用
        return Math.max(nums);
    }
}

调用库中的外部函数

接着我们来看调用库中的外部函数时,会发生什么,我们还是用上面的例子,但是这次将 max 标志为 external

LibraryExternal.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;

library Math {
    // 库中的外部函数
    function max(uint a, uint b) external pure returns (uint) {
        return a > b ? a : b;
    }
}

contract LibraryExternal {
    function test() public pure {
        Math.max(0, 1);
    }
}

对于 internal 版本,部署成本为 95762 gas,调用 test 函数成本为 206 gas

而对于 external 版本,部署成本为 183739 gas,调用 test 函数成本为 4378 gas

可以看到 external 版本,部署成本是 internal 版本的两倍。这是因为当我们调用库中的外部函数时,我们不但部署了当前合约,还部署了 Math 库。

external 版本中,调用 test 函数的成本是 internal 版本的 20 多倍。这是因为当我们调用库的外部函数时,最终实际上是通过 EVM 消息调用的方式(类似于合约外部调用):根据库的部署地址,以及 max 的函数选择子,找到库的部署代码中的 max 函数代码并执行,整个成本要比直接 jump 跳转高很多。

库的部署方式

下面简单讲一下,调用库的外部函数时的编译过程和部署过程。

在编译时,由于我们还不知道库 Math 最终会部署到哪一个以太坊地址,Solidity 会根据以下方式得到一个地址占位符:

// 占位符计算:
"__$" + bytes17(keccak256(bytes("库文件路径:库名称"))) + "__$"

例如在上例上,我们查看编译之后的字节码可以看到:

...5b73__$f97769693a1ff24517255cd3d5a093436a$__636d5...

其中 __$f97769693a1ff24517255cd3d5a093436a$__ 即是库 Math 的地址占位符。中间的占位码是使用 XLCYun/unileg_solidity_code/LibraryExternal.sol:Math 通过上面的占位符计算方法计算得到的。

当我们编译后的合约代码提交到以太坊网络进行部署时,EVM 会首先部署 Math 库到某一个地址 A,然后使用 A,替换掉字节码中的地址占用符 __$f97769693a1ff24517255cd3d5a093436a$__

得到新的合约代码之后,再部署新的合约代码。整个过程就完成了。

库的外部函数的 this

既然库也是会被单独部署,那么当我们调用库的外部函数时,this 是指向库(地址),还是指向当前合约实例(地址)呢?

LibraryExternalThis.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;

library Math {
    // 库中的外部函数,返回库外部函数中的 this
    function libAddress() external view returns (address) {
        return address(this);
    }
}

contract LibraryExternal {
    function sameThis() public view returns (bool) {
        return address(this) == Math.libAddress();
    }
}

部署以上合约,并运行 sameThis,可以看到结果为 true。因此,即使是调用库的外部函数,其上下文仍保留为当前合约实例。

因此,库的外部函数也可以接收一个状态变量的引用,从而实现对状态变量的读取和修改。

库的内外部函数总结

库的内外部函数对比,可以总结如下:

  1. 调用库的内部函数,将直接把内部函数代码插入到当前合约中,使用 jump 直接调用,gas 耗费少
  2. 调用库的外部函数,将一同部署库到以太坊,调用时类似调用合约外部调用,gas 耗费大
  3. 无论是内部函数,还是外部函数,在库函数内部,执行的上下文还是原合约实例,this 仍然指向原合约实例。因此,只要拿到状态变量的引用,库函数也可以访问状态变量。

集合库实现

库的使用,除以上面的示例,还有一种更流行的使用方式,那就是结合 using for 实现复杂的数据结构。

我们要使用使用库来实现一个 集合 类型。该类型支持:

  • 插入操作
  • 移除操作
  • 判断元素是否在集合中
  • 并集操作
  • 差集操作

我们创建一个文件 Set.sol 用于保存该库的代码:

Set.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;

/// @dev 数组实现的集合
struct Set {
    int[] elements;
}

/// @dev 数组实现的集合操作
library SetOp {
    /// @notice 判断集合内是否存在存在某元素
    /// @param self 集合结构体
    /// @param v 要查找的元素
    /// @return bool true 表示存在,false 表示不存在
    function has(Set storage self, int v) internal view returns (bool) {
        for (uint i = 0; i < self.elements.length; i++)
            if (self.elements[i] == v) return true;
        return false;
    }

    /// @notice 插入元素到集合中
    /// @param self 集合结构体
    /// @param v 要插入的元素
    /// @return bool true 表示插入成功,false 表示元素已存在
    function insert(Set storage self, int v) internal returns (bool) {
        if (has(self, v)) return false;
        self.elements.push(v);
        return true;
    }

    /// @notice 从集合中移除元素
    /// @param self 集合结构体
    /// @param v 要移除的元素
    /// @return bool true 表示移除成功,false 表示元素不存在
    function remove(Set storage self, int v) internal returns (bool) {
        for (uint i = 0; i < self.elements.length; i++)
            if (self.elements[i] == v) {
                for (i++; i < self.elements.length; i++)
                    self.elements[i - 1] = self.elements[i];
                self.elements.pop();
                return true;
            }
        return false;
    }

    /// @notice 并集操作
    /// @param self 集合结构体,并集结果仍存回此集合
    /// @param unionSet 将此集合元素并入 self 集合
    function union(Set storage self, Set storage unionSet) internal {
        for (uint i = 0; i < unionSet.elements.length; i++)
            insert(self, unionSet.elements[i]);
    }

    /// @notice 交集操作
    /// @param self 集合结构体,交集结果仍存回此集合
    /// @param diffSet 将此集合元素与 self 取交集,不修改此集合
    function diff(Set storage self, Set storage diffSet) internal {
        for (uint i = 0; i < diffSet.elements.length; i++)
            remove(self, diffSet.elements[i]);
    }
}

在我们的合约文件内,我们导入该库并尝试使用它:

TestSet.sol
运行
复制
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.1 <0.9.0;

// 导入 SetOp 库
import { SetOp, Set } from "./Set.sol";

contract TestSet {
    Set set1;
    Set set2;

    function test() public returns (Set memory, Set memory) {
        SetOp.insert(set1, 0);
        SetOp.insert(set1, 2);
        SetOp.insert(set1, 4);
        SetOp.insert(set1, 6);
        // set1: 0, 2, 4, 6

        SetOp.insert(set2, 1);
        SetOp.insert(set2, 3);
        SetOp.insert(set2, 5);
        SetOp.insert(set2, 7);
        // set2: 1, 3, 5, 7 

        SetOp.remove(set1, 2);
        SetOp.remove(set2, 5);
        // set1: 0, 4, 6
        // set2: 1, 3, 7

        SetOp.union(set1, set2);
        // set1: 0, 4, 6, 1, 3, 7
        // set2: 1, 3, 7

        SetOp.diff(set2, set1);
        // set1: 0, 4, 6, 1, 3, 7
        // set2: 

        return (set1, set2);
    }
}