Solidity 101

学习Solidity 101 记录

Solidity 是一种用于编写以太坊虚拟机(EVM)智能合约的编程语言。

开发工具

本教程中,我们将使用 Remix 运行 Solidity 合约。Remix 是以太坊官方推荐的智能合约集成开发环境(IDE),适合新手,可以在浏览器中快速开发和部署合约,无需在本地安装任何程序。网址:https://remix.ethereum.org

Hello world

pragma solidity ^0.8.21;// 表示源文件将不允许小于 0.8.21 版本或大于等于 0.9.0 的编译器编译(第二个条件由 ^ 提供)
contract HelloWeb3 { // 创建合约(contract),合约名为 HelloWeb3
    string public _string = "hello web3";
}

Remix 会使用 Remix 虚拟机(以前称为 JavaScript 虚拟机)来模拟以太坊链,运行智能合约,类似在浏览器里运行一条测试链。

Remix 还会为你分配一些测试账户,每个账户里有 100 ETH(测试代币),随意使用。

点击部署 Deploy 即可部署合约。

值类型

  • 值类型(Value Type) :包括布尔型,整数型等等,这类变量赋值时候直接传递数值。
  • 引用类型(Reference Type) :包括数组和结构体,这类变量占空间大,赋值时候直接传递地址(类似指针)。
  • 映射类型(Mapping Type) : Solidity中存储键值对的数据结构,可以理解为哈希表

布尔

bool public _bool = true;

整形

int public _int = -1;
uint public _uint = 1;
uint256 public _number = 123456;

uint256 public _number1 = _number + 1; // +,-,*,/
uint256 public _number2 = 2**2; // 指数
uint256 public _number3 = 7 % 2; // 取余数
bool public _numberbool = _number2 > _number3; // 比大小

地址类型

有两类:

  • 普通地址:存储一个 20 字节的值(以太坊地址的大小)。
  • payable address: 比普通地址多了 transfer 和 send 两个成员方法,用于接收转账。
address public _address = 0x......;
address payable public _add1 = payable(_adderss) // 可以转账、查余额
uint256 public balance = _address.balance;

定长字节数组

  • 定长字节数组: 属于值类型,数组长度在声明之后不能改变。根据字节数组的长度分为 bytes1, bytes8, bytes32 等类型。定长字节数组最多存储 32 bytes 数据,即bytes32。
  • 不定长字节数组: 属于引用类型(之后的章节介绍),数组长度在声明之后可以改变,包括 bytes 等。
bytes32 public _byte32 = "MiniSolidity"; 
bytes1 public _byte = _byte32[0]; // 0x4d

枚举

类似C预言中的枚举

// 用enum将uint 0, 1, 2表示为Buy, Hold, Sell
enum ActionSet { Buy, Hold, Sell }
// 创建enum变量 action
ActionSet action = ActionSet.Buy;

函数

Solidity 中函数的形式:

// 方括号中的是可写可不 写的关键字
function <function name>([parameter types[, ...]]) {internal|external|public|private} [pure|view|payable] [virtual|override] [<modifiers>]
[returns (<return types>)]
{ 
    <function body> 
    }

解释:

  • function:声明函数时的固定用法。
  • <function name>:函数名。
  • ([parameter types[, ...]]):圆括号内写入函数的参数,即输入到函数的变量类型和名称。
  • {internal|external|public|private}:函数可见性说明符,共有4种。
    • public:内部和外部均可见。
    • private:只能从本合约内部访问,继承的合约也不能使用。
    • external:只能从合约外部访问(但内部可以通过 this.f() 来调用,f是函数名)。
    • internal: 只能从合约内部访问,继承的合约可以用。
    • 注意
      • 1:合约中定义的函数需要明确指定可见性,它们没有默认值。
      • 2:public|private|internal 也可用于修饰状态变量。public变量会自动生成同名的getter函数,用于查询数值。未标明可见性类型的状态变量,默认为internal。
  • [pure|view|payable]:决定函数权限/功能的关键字。
    • payable(可支付的)很好理解,带着它的函数,运行的时候可以给合约转入 ETH。
    • pure 和 view 的介绍见下一节。
  • [virtual|override]: 方法是否可以被重写,或者是否是重写方法。virtual用在父合约上,标识的方法可以被子合约重写。override用在自合约上,表明方法重写了父合约的方法。
  • <modifiers>: 自定义的修饰器,可以有0个或多个修饰器。
  • [returns ()]:函数返回的变量类型和名称。
  • <function body>: 函数体。

solidity 引入pure|view这两个关键字主要是因为 以太坊交易需要支付gas。合约的状态变量存储在链上,gas fee 很贵,如果计算不改变链上状态,就可以不用付 gas。包含 pure 和 view 关键字的函数是不改写链上状态的,因此用户直接调用它们是不需要付 gas 的(注意,合约中非 pure/view 函数调用 pure/view 函数时需要付gas)。

  • pure,中文意思是“纯”,这里可以理解为”纯打酱油的”。pure 函数既不能读取也不能写入链上的状态变量。
  • view,“看”,这里可以理解为“看客”。view函数能读取但也不能写入状态变量。
  • 非 pure 或 view 的函数既可以读取也可以写入状态变量。

在以太坊中,以下语句被视为修改链上状态:

  • 写入状态变量。
  • 释放事件。
  • 创建其他合约。
  • 使用 selfdestruct.
  • 通过调用发送以太币。
  • 调用任何未标记 view 或 pure 的函数。
  • 使用低级调用(low-level calls)。
  • 使用包含某些操作码的内联汇编。

pure vs view

pragma solidity ^0.8.21;
contract FunctionTypes {
    uint256 public number = 5;

    function add() external {// 这个函数不能标记为pure,
        number = number + 1;
    }

    // pure: 不能读写链上的状态变量
    function addPure(uint256 _number) external pure returns(uint256 new_number){
        new_number = _number + 1;
    }

    // view: 可以读不可以写
    function addView() external view returns(uint256 new_number) {
        new_number = number + 1;
    }

}

internal vs external

由于 internal 函数只能由合约内部调用,我们必须再定义一个 external 的 minusCall() 函数,通过它间接调用内部的 minus() 函数。

pragma solidity ^0.8.21;
contract FunctionTypes {
    uint256 public number = 5;

    function minus() internal{ // 内部函数
        number = number - 1;
    }

    // 合约内的函数可以调用内部函数
    function minusCall() external {
        minus();
    }

}

payable

如果在remix的value中设置了1eth,点击minusPayable后,这1eth会转入合约地址,合约地址的余额会增加:

{
	"0": "uint256: balance 1000000000000000000"
}
pragma solidity ^0.8.21;
contract FunctionTypes {
    uint256 public number = 5;

    function minus() internal{
        number = number - 1;
    }

    function minusCall() external {
        minus();
    }
    
    // 能给合约支付eth的函数
    function minusPayable() external payable returns(uint256 balance){
        minus();
        balance = address(this).balance;
    }

}

函数输出

有两个:return 和 returns

  • returns:跟在函数名后面,用于声明返回的变量类型及变量名。
  • return:用于函数主体中,返回指定的变量。
function returnMultiple() public pure returns(uint256,bool,uint256[3] memory)
{
    return(1,true,[uint256(1),2,5]);
}

因为[1,2,3]会默认为uint8(3),因此[uint256(1),2,5]中首个元素必须强转uint256来声明该数组内的元素皆为此类型。数组类型返回值默认必须用memory修饰。

命名式返回

function returnNamed() public pure returns(uint256 _number, bool _bool, uint256[3] memory _array){
    _number = 2;
    _bool = false;
    _array = [uint256(3),2,1];
}

结构式复制

function readReturn() public pure{
    // 读取所有返回值
    uint256 _number;
    bool _bool;
    uint256[3] memory _array;
    (_number, _bool, _array) = returnNamed();

    // 读取部分
    (, _bool2, ) = returnNamed();
}

变量数据存储和作用域

引用类型包括数组和结构体,由于这类变量比较复杂,占用存储空间大,在使用时必须要声明数据存储的位置。

Solidity数据存储位置有三类:storage,memory和calldata。不同存储位置的gas成本不同。storage类型的数据存在链上,类似计算机的硬盘,消耗gas多;memory和calldata类型的临时存在内存里,消耗gas少。整体消耗gas从多到少依次为:storage > memory > calldata

与memory的不同点在于calldata变量不能修改(immutable),一般用于函数的参数。

function fCalldata(uint[] calldata _x) public pure returns(uint[] calldata){
    //参数为calldata数组,不能被修改
    // _x[0] = 0 //这样修改会报错
    return(_x);
}

在不同存储类型相互赋值时候,有时会产生独立的副本(修改新变量不会影响原变量),有时会产生引用(修改新变量会影响原变量)

赋值本质上是创建引用指向本体,因此修改本体或者是引用,变化可以被同步。

  1. storage(合约的状态变量)赋值给本地storage(函数里的)时候,会创建引用,改变新变量会影响原变量。
uint[] x = [1,2,3]; // 状态变量:数组 x

function fStorage() public{
    //声明一个storage的变量 xStorage,指向x。修改xStorage也会影响x
    uint[] storage xStorage = x;
    xStorage[0] = 100;
}
  1. memory赋值给memory,会创建引用,改变新变量会影响原变量。

  2. 其他情况下,赋值创建的是本体的副本,即对二者之一的修改,并不会同步到另一方。这有时会涉及到开发中的问题,比如从storage中读取数据,赋值给memory,然后修改memory的数据,但如果没有将memory的数据赋值回storage,那么storage的数据是不会改变的。

变量的作用域

有三种,分别是状态变量(state variable),局部变量(local variable)和全局变量(global variable)

  1. 状态变量存储在链上,所有合约内函数都可以访问,gas消耗高。状态变量在合约内、函数外声明。
contract Variables {
    uint public x = 1;
    uint public y;
    string public z;
}
  1. 局部变量是仅在函数执行过程中有效的变量,函数退出后,变量无效。局部变量不上链,gas低。

  2. 全局变量是全局范围工作的变量,都是solidity预留关键字。他们可以在函数内不声明直接使用。

function global() external view returns(address, uint, bytes memory){
    address sender = msg.sender; // 请求发起地址
    uint blockNum = block.number;// 当前区块高度
    bytes memory data = msg.data;// 请求的数据
    return(sender, blockNum, data);
}

常见的全局变量:

  • blockhash(uint blockNumber): (bytes32):给定区块的哈希值 – 只适用于最近的256个区块,不包含当前区块。
  • block.coinbase: (address payable):当前区块矿工的地址。
  • block.gaslimit: (uint):当前区块的gaslimit。
  • block.number: (uint):当前区块的number。
  • block.timestamp: (uint):当前区块的时间戳,为unix纪元以来的秒。
  • gasleft(): (uint256):剩余 gas。
  • msg.data: (bytes calldata):完整call data。
  • msg.sender: (address payable):消息发送者 (当前caller)。
  • msg.sig: (bytes4):calldata的前四个字节 (function identifier)。
  • msg.value: (uint):当前交易发送的wei值。
  • block.blobbasefee: (uint):当前区块的blob基础费用。这是Cancun升级新增的全局变量。
  • blobhash(uint index): (bytes32):返回跟当前交易关联的第index个blob的版本化哈希(第一个字节为版本号,当前为0x01,后面接KZG承诺的SHA256哈希的最后31个字节)。若当前交易不包含blob,则返回空字节。这是Cancun升级新增的全局变量。

以太单位

Solidity中不存在小数点,以0代替为小数点,来确保交易的精确度,并且防止精度的损失:

  • wei: 1
  • gwei: 1e9 = 1000000000
  • ether: 1e18 = 1000000000000000000

时间单位

可以在合约中规定一个操作必须在一周内完成,或者某个事件在一个月后发生。这样就能让合约的执行可以更加精确,不会因为技术上的误差而影响合约的结果。

  • seconds: 1
  • minutes: 60 seconds = 60
  • hours: 60 minutes = 3600
  • days: 24 hours = 86400
  • weeks: 7 days = 604800

引用类型

有两个,一个是数组array,一个是结构体struct

数组

有定长和变长两种

// 固定长度 Array
uint[8] array1;
bytes1[5] array2;
address[100] array3;

// 可变长度 Array
uint[] array4;
bytes1[] array5;
address[] array6;
bytes array7;
// bytes比较特殊,是数组,但是不用加[]。另外,不能用byte[]声明单字节数组,可以使用bytes或bytes1[]。
// bytes 比 bytes1[] 省gas。

对于memory修饰的动态数组,可以用new操作符来创建,但是必须声明长度,并且声明后长度不能改变。

// memory动态数组
uint[] memory array8 = new uint[](5);
bytes memory array9 = new bytes(9);

如果创建的是动态数组,需要一个一个元素的赋值。

uint[] memory x = new uint[](3);
x[0] = 1;
x[1] = 3;
x[2] = 4;

成员:

  • length: 数组有一个包含元素数量的length成员,memory数组的长度在创建后是固定的。
  • push(): 动态数组拥有push()成员,可以在数组最后添加一个0元素,并返回该元素的引用。
  • push(x): 动态数组拥有push(x)成员,可以在数组最后添加一个x元素。
  • pop(): 动态数组拥有pop()成员,可以移除数组最后一个元素。

结构体

有四种赋值的方法:

// 结构体
struct Student{
    uint256 id;
    uint256 score; 
}

Student student; // 初始一个student结构体



//  给结构体赋值
// 方法1:在函数中创建一个storage的struct引用
function initStudent1() external{
    Student storage _student = student; // assign a copy of student
    _student.id = 11;
    _student.score = 100;
}

// 方法2:直接引用状态变量的struct
function initStudent2() external{
    student.id = 1;
    student.score = 80;
}

// 方法3:构造函数式
function initStudent3() external {
    student = Student(3, 90);
}

// 方法4:key value
function initStudent4() external {
    student = Student({id: 4, score: 60});
}

映射类型

声明映射的格式为mapping(_KeyType => _ValueType),其中_KeyType_ValueType分别是Key和Value的变量类型。

mapping(uint => address) public idToAddress; // id映射到地址
mapping(address => address) public swapPair; // 币对的映射,地址到地址

映射的规则

  1. 映射的_KeyType只能选择Solidity内置的值类型,比如uintaddress等,不能用自定义的结构体。而_ValueType可以使用自定义的类型。

  2. 映射的存储位置必须是storage,因此可以用于合约的状态变量,不能用于public函数的参数或返回结果中,因为mapping记录的是一种关系 (key - value pair)。

  3. 如果映射声明为public,那么Solidity会自动给你创建一个getter函数,可以通过Key来查询对应的Value。

  4. 给映射新增的键值对的语法为_Var[_Key] = _Value,其中_Var是映射变量名,_Key_Value对应新增的键值对。

function writeMap(uint _key, address _value) public {
    idToAddress[_key] = _value;
}

变量的初始值

  • 值类型初始值

    • boolean: false
    • string: ""(空字符串)
    • int/uint: 0
    • enum: 枚举第一个元素(如enum Action {Buy, Hold}初始为Buy
    • address: 0x0000000000000000000000000000000000000000
    • function(内部/外部): 空白函数
  • 引用类型初始值

    • mapping: 所有元素为对应类型默认值
    • struct: 所有成员为默认值(如struct Student {uint id;}初始id=0
    • 动态数组: [](空数组)
    • 静态数组: 所有成员为默认值(如uint[3]初始[0,0,0]
  • delete操作符:将变量重置为初始值(如delete _bool_bool设为false

常数

constant(常量)和immutable(不变量)。

  • constantimmutable 关键字

    • 用于状态变量,初始化后值不可改,提升安全性且节省gas。
    • 仅数值变量适用,stringbytes可声明为constant,不可为immutable
  • constant 特性

    • 必须声明时初始化,后续不可修改,否则编译报错。
    • 示例:uint256 constant MY_CONST = 10;
// constant变量必须在声明的时候初始化,之后不能改变
uint256 constant CONSTANT_NUM = 10;
string constant CONSTANT_STRING = "0xAA";
bytes constant CONSTANT_BYTES = "WTF";
address constant CONSTANT_ADDRESS = 0x0000000000000000000000000000000000000000;
  • immutable 特性
    • 可在声明时或构造函数中初始化(v0.8.21后未显式初始化用数值初始值)。
    • 声明与构造函数均初始化时,以构造函数为准,支持全局变量/自定义函数初始化。
    • 示例:uint256 immutable IMMUTABLE_VAL; constructor() { IMMUTABLE_VAL = 20; }
// immutable变量可以在constructor里初始化,之后不能改变
uint256 public immutable IMMUTABLE_NUM = 9999999999;
// 在`Solidity v8.0.21`以后,下列变量数值暂为初始值
address public immutable IMMUTABLE_ADDRESS; 
uint256 public immutable IMMUTABLE_BLOCK;
uint256 public immutable IMMUTABLE_TEST;

控制流

也就是if else while for 之类的,和其他语言一样。

构造函数和修饰器

构造函数(constructor),修饰器(modifier)。

构造函数

每个合约可以定义一个,并在部署合约的时候自动运行一次。

address owner;
constructor(address initOwner){
    owner = initOwner;
}

修饰器 modifier

类似面向对象编程中的装饰器,声明函数拥有的特性,并减少代码冗余。modifier的主要使用场景是运行函数前的检查,例如地址,变量,余额等。

定义了一个changeOwner函数,运行它可以改变合约的owner,但是由于onlyOwner修饰符的存在,只有原先的owner可以调用,别人调用就会报错。这也是最常用的控制智能合约权限的方法。

// 定义modifier
modifier onlyOwner {
   require(msg.sender == owner); // 检查调用者是否为owner地址
   _; // 如果是的话,继续运行函数主体;否则报错并revert交易
}

// 带有onlyOwner修饰符的函数只能被owner地址调用,比如下面这个例子
function changeOwner(address _newOwner) external onlyOwner{
   owner = _newOwner; // 只有owner地址运行这个函数,并改变owner
}

事件 event

  • 事件(event)定义:EVM日志的抽象,用于记录合约关键操作,支持前端监听响应,且存储成本低(约2,000 gas/次,远低于链上存储)。

声明事件

事件的声明由event关键字开头,接着是事件名称,括号里面写好事件需要记录的变量类型和变量名。以ERC20代币合约的Transfer事件为例:

fromto前面带有indexed关键字,他们会保存在以太坊虚拟机日志的topics中,方便之后检索。

event Transfer(address indexed from, address indexed to, uint256 value);

释放事件

在下面的例子中,每次用_transfer()函数进行转账操作的时候,都会释放Transfer事件,并记录相应的变量。

// 定义_transfer函数,执行转账逻辑
function _transfer(
    address from,
    address to,
    uint256 amount
) external {

    _balances[from] = 10000000; // 给转账地址一些初始代币

    _balances[from] -=  amount; // from地址减去转账数量
    _balances[to] += amount; // to地址加上转账数量

    // 释放事件
    emit Transfer(from, to, amount);
}

EVM 日志

以太坊虚拟机(EVM)用日志Log来存储Solidity事件,每条日志记录都包含主题topics和数据data两部分。

alt text

主题 topics

日志的第一部分是主题数组,用于描述事件,长度不能超过4。它的第一个元素是事件的签名(哈希)。对于上面的Transfer事件,它的事件哈希就是:

keccak256("Transfer(address,address,uint256)")

//0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef

indexed标记的参数可以理解为检索事件的索引“键”,方便之后搜索。每个 indexed 参数的大小为固定的256比特,如果参数太大了(比如字符串),就会自动计算哈希存储在主题中。

数据 data

事件中不带 indexed 的参数会被存储在 data 部分中,可以理解为事件的“值”。data 部分的变量不能被直接检索,但可以存储任意大小的数据。因此一般 data 部分可以用来存储复杂的数据结构,例如数组和字符串等等,因为这些数据超过了256比特,即使存储在事件的 topics 部分中,也是以哈希的方式存储。另外,data 部分的变量在存储上消耗的gas相比于 topics 更少。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;
contract FunctionTypes {
    mapping ( address => uint256 ) private _balances;

    event Transfer(address indexed from, address indexed to, uint256 value);

    // 定义_transfer函数,执行转账逻辑
    function _transfer(
        address from,
        address to,
        uint256 amount
    ) external {

        _balances[from] = 10000000;
        _balances[from] -=  amount;
        _balances[to] += amount;

        // 释放事件
        emit Transfer(from, to, amount);
    }
}

部署后可以看到日志:

其中from式产生日志的合约地址,topic是事件签名(哈希),args部分是事件参数

继承 inheritance

包括简单继承,多重继承,以及修饰器(Modifier)和构造函数(Constructor)的继承。

规则

  • virtual: 父合约中的函数,如果希望子合约重写,需要加上virtual关键字。
  • override:子合约重写了父合约中的函数,需要加上override关键字。

简单继承

contract Yeye {
    event Log(string msg);

    // 定义3个function: hip(), pop(), yeye(),Log值为Yeye。
    function hip() public virtual{
        emit Log("Yeye");
    }

    function pop() public virtual{
        emit Log("Yeye");
    }

    function yeye() public virtual {
        emit Log("Yeye");
    }
}

在写一个继承Yeye合约:

contract Baba is Yeye{
    // 继承两个function: hip()和pop(),输出改为Baba。
    function hip() public virtual override{
        emit Log("Baba");
    }

    function pop() public virtual override{
        emit Log("Baba");
    }

    function baba() public virtual{
        emit Log("Baba");
    }
}

多重继承

  • 继承时要按辈分最高到最低的顺序排。比如写一个Erzi合约,继承Yeye合约Baba合约,那么就要写成contract Erzi is Yeye, Baba,而不能写成contract Erzi is Baba, Yeye,不然就会报错。

  • 如果某一个函数在多个继承的合约里都存在,比如例子中的hip()pop(),在子合约里必须重写,不然会报错。

  • 重写在多个父合约中都重名的函数时,override关键字后面要加上所有父合约名字,例如override(Yeye, Baba)

contract Erzi is Yeye, Baba{
    // 继承两个function: hip()和pop(),输出值为Erzi。
    function hip() public virtual override(Yeye, Baba){
        emit Log("Erzi");
    }

    function pop() public virtual override(Yeye, Baba) {
        emit Log("Erzi");
    }
}

修饰器的继承

Solidity中的修饰器(Modifier)同样可以继承,用法与函数继承类似,在相应的地方加virtualoverride关键字即可。

contract Base1 {
    modifier exactDividedBy2And3(uint _a) virtual {
        require(_a % 2 == 0 && _a % 3 == 0);
        _;
    }
}

contract Identifier is Base1 {

    //计算一个数分别被2除和被3除的值,但是传入的参数必须是2和3的倍数
    function getExactDividedBy2And3(uint _dividend) public exactDividedBy2And3(_dividend) pure returns(uint, uint) {
        return getExactDividedBy2And3WithoutModifier(_dividend);
    }

    //计算一个数分别被2除和被3除的值
    function getExactDividedBy2And3WithoutModifier(uint _dividend) public pure returns(uint, uint){
        uint div2 = _dividend / 2;
        uint div3 = _dividend / 3;
        return (div2, div3);
    }
}

构造函数的继承

子合约有两种方法继承父合约的构造函数。举个简单的例子,父合约A里面有一个状态变量a,并由构造函数的参数来确定:

// 构造函数的继承
abstract contract A {
    uint public a;

    constructor(uint _a) {
        a = _a;
    }
}
  1. 在继承时声明父构造函数的参数,例如:contract B is A(1)
  2. 在子合约的构造函数中声明构造函数的参数,例如:
contract C is A {
    constructor(uint _c) A(_c * _c) {}
}

调用父合约的函数

有两种:

  1. 直接调用:父合约名.函数名()
function callParent() public{
    Yeye.pop();
}
  1. 使用super关键字:子合约可以利用super.函数名()来调用最近的父合约函数。

Solidity继承关系按声明时从右到左的顺序是:contract Erzi is Yeye, Baba,那么Baba是最近的父合约,super.pop()将调用Baba.pop()而不是Yeye.pop():

function callParentSuper() public{
    // 将调用最近的父合约函数,Baba.pop()
    super.pop();
}

菱形继承

在多重+菱形继承链条上使用super关键字时,需要注意的是使用super会调用继承链条上的每一个合约的相关函数,而不是只调用最近的父合约。

在下面的例子中,调用合约people中的super.bar()会依次调用EveAdam,最后是God合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

/* 继承树:
  God
 /  \
Adam Eve
 \  /
people
*/

contract God {
    event Log(string message);

    function foo() public virtual {
        emit Log("God.foo called");
    }

    function bar() public virtual {
        emit Log("God.bar called");
    }
}

contract Adam is God {
    function foo() public virtual override {
        emit Log("Adam.foo called");
        super.foo();
    }

    function bar() public virtual override {
        emit Log("Adam.bar called");
        super.bar();
    }
}

contract Eve is God {
    function foo() public virtual override {
        emit Log("Eve.foo called");
        super.foo();
    }

    function bar() public virtual override {
        emit Log("Eve.bar called");
        super.bar();
    }
}

contract people is Adam, Eve {
    function foo() public override(Adam, Eve) {
        super.foo();
    }

    function bar() public override(Adam, Eve) {
        super.bar();
    }
}

抽象合约和接口

这里使用ERC721的接口合约为例介绍Solidity中的抽象合约(abstract)接口(interface)

抽象合约

如果一个智能合约里至少有一个未实现的函数,即某个函数缺少主体{}中的内容,则必须将该合约标为abstract,不然编译会报错;另外,未实现的函数需要加virtual,以便子合约重写。

abstract contract InsertionSort{
    function insertionSort(uint[] memory a) public pure virtual returns(uint[] memory);
}

接口

接口类似于抽象合约,但不实现任何功能。

  • 接口的规则
    • 不能包含状态变量
    • 不能包含构造函数
    • 不能继承除接口外的其他合约
    • 所有函数都必须是external且不能有函数体
    • 继承接口的非抽象合约必须实现接口定义的所有功能

虽然接口不实现任何功能,但它非常重要。接口是智能合约的骨架,定义了合约的功能以及如何触发它们:如果智能合约实现了某种接口(比如ERC20或ERC721),其他Dapps和智能合约就知道如何与它交互。因为接口提供了两个重要的信息:

  1. 合约里每个函数的bytes4选择器,以及函数签名函数名(每个参数类型)。
  2. 接口id(更多信息见EIP165)

另外,接口与合约ABI(Application Binary Interface)等价,可以相互转换:编译接口可以得到合约的ABI,利用abi-to-sol工具,也可以将ABI json文件转换为接口sol文件。

以ERC721接口合约IERC721为例,它定义了3个event和9个function,所有ERC721标准的NFT都实现了这些函数。我们可以看到,接口和常规合约的区别在于每个函数都以;代替函数体{ }结尾。

interface IERC721 is IERC165 {
    event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
    event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
    event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
    
    function balanceOf(address owner) external view returns (uint256 balance);

    function ownerOf(uint256 tokenId) external view returns (address owner);

    function safeTransferFrom(address from, address to, uint256 tokenId) external;

    function transferFrom(address from, address to, uint256 tokenId) external;

    function approve(address to, uint256 tokenId) external;

    function getApproved(uint256 tokenId) external view returns (address operator);

    function setApprovalForAll(address operator, bool _approved) external;

    function isApprovedForAll(address owner, address operator) external view returns (bool);

    function safeTransferFrom( address from, address to, uint256 tokenId, bytes calldata data) external;
}

IERC721事件:

  • Transfer事件:在转账时被释放,记录代币的发出地址from,接收地址to和tokenId。
  • Approval事件:在授权时被释放,记录授权地址owner,被授权地址approved和tokenId。
  • ApprovalForAll事件:在批量授权时被释放,记录批量授权的发出地址owner,被授权地址operator和授权与否的approved。

IERC721函数:

  • balanceOf:返回某地址的NFT持有量balance。
  • ownerOf:返回某tokenId的主人owner。
  • transferFrom:普通转账,参数为转出地址from,接收地址to和tokenId。
  • safeTransferFrom:安全转账(如果接收方是合约地址,会要求实现ERC721Receiver接口)。参数为转出地址from,接收地址to和tokenId。
  • approve:授权另一个地址使用你的NFT。参数为被授权地址approve和tokenId。
  • getApproved:查询tokenId被批准给了哪个地址。
  • setApprovalForAll:将自己持有的该系列NFT批量授权给某个地址operator。
  • isApprovedForAll:查询某地址的NFT是否批量授权给了另一个operator地址。
  • safeTransferFrom:安全转账的重载函数,参数里面包含了data。

什么时候使用接口?

如果我们知道一个合约实现了IERC721接口,我们不需要知道它具体代码实现,就可以与它交互。

无聊猿BAYC属于ERC721代币,实现了IERC721接口的功能。我们不需要知道它的源代码,只需知道它的合约地址,用IERC721接口就可以与它交互,比如用balanceOf()来查询某个地址的BAYC余额,用safeTransferFrom()来转账BAYC。

contract interactBAYC {
    // 利用BAYC地址创建接口合约变量(ETH主网)
    IERC721 BAYC = IERC721(0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D);

    // 通过接口调用BAYC的balanceOf()查询持仓量
    function balanceOfBAYC(address owner) external view returns (uint256 balance){
        return BAYC.balanceOf(owner);
    }

    // 通过接口调用BAYC的safeTransferFrom()安全转账
    function safeTransferFromBAYC(address from, address to, uint256 tokenId) external{
        BAYC.safeTransferFrom(from, to, tokenId);
    }
}

异常

三种抛出异常的方法:errorrequireassert

error方法gas最少,其次是assertrequire方法消耗gas最多。

error 异常

error 异常方便且高效(省gas)。同时还可以在抛出异常的同时携带参数,帮助开发者更好地调试。

error TransferNotOwner(); // 自定义error
error TransferNotOwner(address sender); // 自定义的带参数的error

在执行当中,error必须搭配revert(回退)命令使用。

定义了一个transferOwner1()函数,它会检查代币的owner是不是发起人,如果不是,就会抛出TransferNotOwner异常;如果是的话,就会转账。

function transferOwner1(uint256 tokenId, address newOwner) public {
    if(_owners[tokenId] != msg.sender){
        revert TransferNotOwner();
        // revert TransferNotOwner(msg.sender);
    }
    _owners[tokenId] = newOwner;
}

require 异常

唯一的缺点就是gas随着描述异常的字符串长度增加,比error命令要高。使用方法:require(检查条件,"异常的描述"),当检查条件不成立的时候,就会抛出异常。

使用require重写上面的:

function transferOwner2(uint256 tokenId, address newOwner) public {
    require(_owners[tokenId] == msg.sender, "Transfer Not Owner");
    _owners[tokenId] = newOwner;
}

assert 异常

assert命令一般用于程序员写程序debug,因为它不能解释抛出异常的原因(比require少个字符串)。它的用法很简单,assert(检查条件),当检查条件不成立的时候,就会抛出异常。

function transferOwner3(uint256 tokenId, address newOwner) public {
    assert(_owners[tokenId] == msg.sender);
    _owners[tokenId] = newOwner;
}