Storage 高效利用——引用 Storage

先看例子:

    struct Proposal {
        address proposer;
        address recipient;
        bytes32 payloadHash;
        uint256 votes;
        uint256 beginBlock;
        mapping(address => bool) voted;
        bool executed;
    }
    Proposal[] public proposals;

    constructor(R3Token _token) {
        token = _token;
    }

    function propose(address recipient, bytes32 payloadHash) external returns (uint256) {
        uint256 proposalIndex = proposals.length;
        proposals.push();
@>       Proposal storage proposal = proposals[proposalIndex];
        proposal.proposer = msg.sender;
        proposal.recipient = recipient;
        proposal.payloadHash = payloadHash;
        proposal.beginBlock = block.number - 1;
        return proposalIndex;
    }

在上面的例子propose函数中,Proposal storage proposal = proposals[proposalIndex] 这行表示的意识是对状态变量proposals[proposalIndex]对应的自定义类型Proposal引用*我最开始认为的是定义状态变量,但是我自己都觉得不对……。这是一种对 Solidity Storage 结构的*高效利用。如果不知道这种利用方式的情况下,我们常见的方法是:

  • 定义一个 Memory 存储方式的 Proposal 结构体(开辟 EVM Memory 空间)
  • 为这个结构体赋值
  • 将这个结构体存储到 Storage 中(写入 Storage)

而这里运用了 引用 Storage 的方法来向状态变量proposals中添加新的成员变量:

  • uint256 proposalIndex = proposals.length获取proposals长度
  • proposals.push()开辟新的数组成员
  • Proposal storage proposal = proposals[proposalIndex]引用 Storage 中,proposals[proposalIndex] 对应元素的Proposal自定义类型
    • 这里涉及到一个细节:数组的 Index 是从0开始的,也就是说,实际上proposals[proposals.length]这个位置是没有被定义的(换种说法,假设数组中有三个元素,数组长度为3,但是数组最后一个元素的索引其实是2
  • 后续便是直接为proposals[proposalIndex]对应的元素进行赋值

对比上述两种方法我们可以看到,下面这种引用 Storage 来为状态变量进行赋值的方法,直接操纵了 Storage,比传统的先在 Memory 中定义要存储的值再存储到 Storage 更节省 gas。(因为第一种方法额外开辟了 Memory)

实际上,在函数中,storage关键字都表示对状态变量(Storage)中的变量的引用

通过内联汇编assembly操纵 Storage

OpenZeppelin OwnableUpgradeable.sol 合约中(节省空间,我去掉了注释):

pragma solidity ^0.8.20;

import {ContextUpgradeable} from "../utils/ContextUpgradeable.sol";
import {Initializable} from "../proxy/utils/Initializable.sol";

abstract contract OwnableUpgradeable is Initializable, ContextUpgradeable {
    /// @custom:storage-location erc7201:openzeppelin.storage.Ownable
    struct OwnableStorage {
        address _owner;
    }

    // keccak256(abi.encode(uint256(keccak256("openzeppelin.storage.Ownable")) - 1)) & ~bytes32(uint256(0xff))
    bytes32 private constant OwnableStorageLocation = 0x9016d09d72d40fdae2fd8ceac6b6234c7706214fd39c1cd1e609a0528c199300;

    function _getOwnableStorage() private pure returns (OwnableStorage storage $) {
        assembly {
            $.slot := OwnableStorageLocation
        }
    }

    error OwnableUnauthorizedAccount(address account);
    error OwnableInvalidOwner(address owner);

    event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);

    function __Ownable_init(address initialOwner) internal onlyInitializing {
        __Ownable_init_unchained(initialOwner);
    }

    function __Ownable_init_unchained(address initialOwner) internal onlyInitializing {
        if (initialOwner == address(0)) {
            revert OwnableInvalidOwner(address(0));
        }
        _transferOwnership(initialOwner);
    }

    modifier onlyOwner() {
        _checkOwner();
        _;
    }

    function owner() public view virtual returns (address) {
        OwnableStorage storage $ = _getOwnableStorage();
        return $._owner;
    }

    function _checkOwner() internal view virtual {
        if (owner() != _msgSender()) {
            revert OwnableUnauthorizedAccount(_msgSender());
        }
    }

    function renounceOwnership() public virtual onlyOwner {
        _transferOwnership(address(0));
    }

    function transferOwnership(address newOwner) public virtual onlyOwner {
        if (newOwner == address(0)) {
            revert OwnableInvalidOwner(address(0));
        }
        _transferOwnership(newOwner);
    }

    function _transferOwnership(address newOwner) internal virtual {
        OwnableStorage storage $ = _getOwnableStorage();
        address oldOwner = $._owner;
        $._owner = newOwner;
        emit OwnershipTransferred(oldOwner, newOwner);
    }
}

这里有几个很有意思的点:

  • Solidity 是允许将变量名称定义为$
  • Solidity 允许我们通过assembly内联汇编代码块直接定义状态变量,并将其定义在我们想要的slot位置(这点在本文后面我们将展开讨论).
  • Solidity 内联汇编的slot指针是变量名称)

“另类”的定义状态变量方法(ERC-7201:命名空间存储布局)

这个库合约,为了避免合约升级时发生存储冲突,采用了命名空间存储布局的方法来定义状态变量

首先,它按照 ERC-7201:命名空间存储布局计算了变量 Ownable实际应该存储的 slot 位置:

// keccak256(abi.encode(uint256(keccak256("openzeppelin.storage.Ownable")) - 1)) & ~bytes32(uint256(0xff))
bytes32 private constant OwnableStorageLocation = 0x9016d09d72d40fdae2fd8ceac6b6234c7706214fd39c1cd1e609a0528c199300;

_getOwnableStorage()函数中,他直接通过assembly内联汇编代码块,将变量$指向OwnableStorage实际存储位置,这个函数的返回值是OwnableStorage结构体(Storage)的引用

function _getOwnableStorage() private pure returns (OwnableStorage storage $) {
        assembly {
            $.slot := OwnableStorageLocation
        }
}

而当用户需要读取Owner是谁的时候,可以通过调用owner()函数:

  • 首先,调用内部函数_getOwnableStorage()获得了结构体OwnableStorage(状态变量)的引用
  • 然后返回了结构体$OwnableStorage)的_owner属性(方法)。
function owner() public view virtual returns (address) {
        OwnableStorage storage $ = _getOwnableStorage();
        return $._owner;
    }

我们再次观察 OpenZeppelin 的合约,实际上,他并没有在高级语言层面(solidity 层面)定义状态变量OwnableStorage。它是运用内联汇编(EVM 层面)直接为对应的 slot 赋值,也就是说,在 solidity 层面,solidity 是不知道状态变量OwnableStorage的存在的,这边需要我们使用assembly内敛汇编代码块,将变量指向对应的 slot ($.slot := ...)。