Solidity基础学习笔记

hloong 于 2022-11-12 发布

Solidity基础学习笔记

这段时间学习了solidity的基础知识,做个笔记参考以免很容易遗忘,学习网站: https://wtf.academy/solidity-start/

数据类型

整数类型

  // 整型
     int public _int = -1; // 整数,包括负数
     uint public _uint = 1; // 正整数
     uint256 public _number = 20220330; // 256位正整数

地址类型

存储一个 20 字节的值(以太坊地址的大小)。地址类型也有成员变量,并作为所有合约的基础。有普通的地址和可以转账ETH的地址(payable)。 payable的地址拥有balance和transfer()两个成员,方便查询ETH余额以及转账。 注:地址本质上是一个160位的数字,可以进行加减,需要强制转换uint160(address)

// 地址
    address public _address = 0x7A58c0Be72BE218B41C608b7Fe7C5bB630736C71;
    address payable public _address1 = payable(_address); // payable address,可以转账、查余额
    // 地址类型的成员
    uint256 public balance = _address1.balance; // balance of address

定长字节数组​

字节数组bytes分两种,一种定长(byte, bytes8, bytes32),另一种不定长。定长的属于数值类型,不定长的是引用类型(之后讲)。 定长bytes可以存一些数据,消耗gas比较少。

 // 固定长度的字节数组
    bytes32 public _byte32 = "MiniSolidity"; 
    bytes1 public _byte = _byte32[0]; 

MiniSolidity变量以字节的方式存储进变量_byte32,转换成16进制为:0x4d696e69536f6c69646974790000000000000000000000000000000000000000 _byte变量存储_byte32的第一个字节,为0x4d。

枚举 enum

枚举(enum)是solidity中用户定义的数据类型。它主要用于为uint分配名称,使程序易于阅读和维护。 它与C语言中的enum类似,使用名称来代替从0开始的uint:

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

它可以显式的和uint相互转换,并会检查转换的正整数是否在枚举的长度内,不然会报错

    // enum可以和uint显式的转换
    function enumToUint() external view returns(uint){
        return uint(action);
    }

enum的一个比较冷门的变量,几乎没什么人用。

函数

函数类型

function <function name> (<parameter types>) {internal|external|public|private} [pure|view|payable] [returns (<return types>)]
function:声明函数时的固定用法,想写函数,就要以function关键字开头。
<function name>:函数名。
(<parameter types>):圆括号里写函数的参数,也就是要输入到函数的变量类型和名字。
{internal|external|public|private}:函数可见性说明符,一共4种。没标明函数类型的,默认internal。

[pure|view|payable]:决定函数权限/功能的关键字。payable(可支付的)很好理解,带着它的函数,运行的时候可以给合约转入ETH。 合约中非pure/view函数调用它们则会改写链上状态,需要付gas,声明pure/view的函数不会改链上状态 [returns ()]:函数返回的变量类型和名称。

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

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

函数输出

返回值return和returns

Solidity有两个关键字与函数输出相关:return和returns,他们的区别在于:

    // 返回多个变量
    function returnMultiple() public pure returns(uint256, bool, uint256[3] memory){
            return(1, true, [uint256(1),2,5]);
    }
        // 命名式返回
    function returnNamed() public pure returns(uint256 _number, bool _bool, uint256[3] memory _array){
        _number = 2;
        _bool = false; 
        _array = [uint256(3),2,1];
    }
        // 命名式返回,依然支持return
    function returnNamed2() public pure returns(uint256 _number, bool _bool, uint256[3] memory _array){
        return(1, true, [uint256(1),2,5]);
    }

上面这段代码最开始的一段中,我们声明了returnMultiple()函数将有多个输出:returns(uint256, bool, uint256[3] memory), 接着我们在函数主体中用return(1, true, [uint256(1),2,5])确定了返回值。 代码第二段第三段为命名式返回,只是标明了返回变量的名称

数据位置

solidity数据存储位置有三类:storage,memory和calldata。不同存储位置的gas成本不同。 storage类型的数据存在链上,类似计算机的硬盘,消耗gas多; memory和calldata类型的临时存在内存里,消耗gas少。大致用法: storage:合约里的状态变量默认都是storage,存储在链上。 memory:函数里的参数和临时变量一般用memory,存储在内存中,不上链。 calldata:和memory类似,存储在内存中,不上链。与memory的不同点在于calldata变量不能修改(immutable),一般用于函数的参数。 例子:

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

赋值规则

变量的作用域

Solidity中变量按作用域划分有三种,分别是状态变量(state variable),局部变量(local variable)和全局变量(global variable)

变量的初始值

值类型初始值

    bool public _bool; // false
    string public _string; // ""
    int public _int; // 0
    uint public _uint; // 0
    address public _address; 
    // 0x0000000000000000000000000000000000000000

    enum ActionSet { Buy, Hold, Sell}
    ActionSet public _enum; // 第一个元素 0

    function fi() internal{} // internal空白方程 
    function fe() external{} // external空白方程 

引用类型初始值

    // Reference Types
    uint[8] public _staticArray; // 所有成员设为其默认值的静态数组[0,0,0,0,0,0,0,0]
    uint[] public _dynamicArray; // `[]`
    mapping(uint => address) public _mapping; // 所有元素都为其默认值的mapping
    // 所有成员设为其默认值的结构体 0, 0
    struct Student{
        uint256 id;
        uint256 score; 
    }
    Student public student;

delete操作符

delete a 会让变量a的值变为初始值。

    // delete操作符
    bool public _bool2 = true; 
    function d() external {
        delete _bool2; // delete 会让_bool2变为默认值,false
    }

数组Array(引用类型)

数组(Array)是solidity常用的一种变量类型,用来存储一组数据(整数,字节,地址等等)。数组分为固定长度数组和可变长度数组两种:

    // 固定长度 Array
    uint[8] array1;
    bytes1[5] array2;
    address[100] array3;
    // 可变长度 Array
    uint[] array4;
    bytes1[] array5;
    address[] array6;
    bytes array7;

创建数组的规则

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

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

数组成员

结构体struct(引用类型)

Solidity支持通过构造结构体的形式定义新的类型。创建结构体的方法:

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

给结构体赋值的2种方法

    //  给结构体赋值
    // 方法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;
    }

映射类型 Mapping

在映射中,人们可以通过键(Key)来查询对应的值(Value),比如:通过一个人的id来查询他的钱包地址。 声明映射的格式为mapping(_KeyType => _ValueType),其中_KeyType和_ValueType分别是Key和Value的变量类型。例子:

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

映射的规则:

规则1:映射的_KeyType只能选择solidity默认的类型,比如uint,address等,不能用自定义的结构体。而_ValueType可以使用自定义的类型

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

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

规则4:给映射新增的键值对的语法为_Var[_Key] = _Value,其中_Var是映射变量名,_Key和_Value对应新增的键值对

映射的原理:

原理1: 映射不储存任何键(Key)的资讯,也没有length的资讯。 原理2: 映射使用keccak256(key)当成offset存取value。 原理3: 因为Ethereum会定义所有未使用的空间为0,所以未赋值(Value)的键(Key)初始值都是0。

常数constant和immutable

solidity中两个关键字,constant(常量)和immutable(不变量)状态变量声明这个两个关键字之后,不能在合约后更改数值; 并且还可以节省gas,只有数值变量可以声明constant和immutable;string和bytes可以声明为constant,但不能为immutable。

constant

constant变量必须在声明的时候初始化,之后再也不能改变。尝试改变的话,编译不通过。

    // constant变量必须在声明的时候初始化,之后不能改变
    uint256 constant CONSTANT_NUM = 10;
    string constant CONSTANT_STRING = "0xAA";
    bytes constant CONSTANT_BYTES = "WTF";
    address constant CONSTANT_ADDRESS = 0x0000000000000000000000000000000000000000;

immutable immutable变量可以在声明时或构造函数中初始化,因此更加灵活。

    // immutable变量可以在constructor里初始化,之后不能改变
    uint256 public immutable IMMUTABLE_NUM = 9999999999;
    address public immutable IMMUTABLE_ADDRESS;
    uint256 public immutable IMMUTABLE_BLOCK;
    uint256 public immutable IMMUTABLE_TEST;
    // 利用constructor初始化immutable变量,因此可以利用
    constructor(){
        IMMUTABLE_ADDRESS = address(this);
        IMMUTABLE_BLOCK = block.number;
        IMMUTABLE_TEST = test();
    }
    function test() public pure returns(uint256){
        uint256 what = 9;
        return(what);
    }

控制流

Solidity的控制流与其他语言类似,主要包含以下几种:

function ifElseTest(uint256 _number) public pure returns(bool){
    if(_number == 0){
    return(true);
    }else{
    return(false);
    }
}
function forLoopTest() public pure returns(uint256){
    uint sum = 0;
    for(uint i = 0; i < 10; i++){
    sum += i;
    }
    return(sum);
}
function whileTest() public pure returns(uint256){
    uint sum = 0;
    uint i = 0;
    while(i < 10){
    sum += i;
    i++;
    }
    return(sum);
}
function doWhileTest() public pure returns(uint256){
    uint sum = 0;
    uint i = 0;
    do{
    sum += i;
    i++;
    }while(i < 10);
    return(sum);
}

三元运算符是solidity中唯一一个接受三个操作数的运算符。条件? 条件为真的表达式:条件为假的表达式 另外还有continue(立即进入下一个循环)和break(跳出当前循环)关键字可以使用。

// 三元运算符 ternary/conditional operator
function ternaryTest(uint256 x, uint256 y) public pure returns(uint256){
    // return the max of x and y
    return x >= y ? x: y; 
}

构造函数

构造函数(constructor)是一种特殊的函数,每个合约可以定义一个,并在部署合约的时候自动运行一次。它可以用来初始化合约的一些参数,例如初始化合约的owner地址:

   address owner; // 定义owner变量

   // 构造函数
   constructor() {
      owner = msg.sender; // 在部署合约的时候,将owner设置为部署者的地址
   }

注意⚠️:构造函数在不同的solidity版本中的语法并不一致,在Solidity 0.4.22之前,构造函数不使用 constructor 而是使用与合约名同名的函数作为构造函数而使用,由于这种旧写法容易使开发者在书写时发生疏漏(例如合约名叫 Parents,构造函数名写成 parents),使得构造函数变成普通函数,引发漏洞,所以0.4.22版本及之后,采用了全新的 constructor 写法 构造函数的旧写法代码示例:

pragma solidity =0.4.21;
contract Parents {
    // 与合约名Parents同名的函数就是构造函数
    function Parents () public {
    }
}

修饰器​

修饰器(modifier)是solidity特有的语法,类似于面向对象编程中的decorator,声明函数拥有的特性,并减少代码冗余。它就像钢铁侠的智能盔甲,穿上它的函数会带有某些特定的行为。modifier的主要使用场景是运行函数前的检查,例如地址,变量,余额等。

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

代有onlyOwner修饰符的函数只能被owner地址调用,比如下面这个例子:

   function changeOwner(address _newOwner) external onlyOwner{
      owner = _newOwner; // 只有owner地址运行这个函数,并改变owner
   }

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

事件​

Solidity中的事件(event)是EVM上日志的抽象,它具有两个特点: 响应:应用程序(ether.js)可以通过RPC接口订阅和监听这些事件,并在前端做响应。 经济:事件是EVM上比较经济的存储数据的方式,每个大概消耗2,000 gas;相比之下,链上存储一个新变量至少需要20,000 gas。

事件的声明由event关键字开头,然后跟事件名称,括号里面写好事件需要记录的变量类型和变量名。以ERC20代币合约的Transfer事件为例: event Transfer(address indexed from, address indexed to, uint256 value);

Transfer事件共记录了3个变量from,to和value,分别对应代币的转账地址,接收地址和转账数量。 每个indexed标记的变量可以理解为检索事件的索引“键”,在以太坊上单独作为一个topic进行存储和索引,程序可以轻松的筛选出特定转账地址和接收地址的转账事件。每个事件最多有3个带indexed的变量。每个 indexed 变量的大小为固定的256比特。 事件的哈希以及这三个带indexed的变量在EVM日志中通常被存储为topic。其中topic[0]是此事件的keccak256哈希,topic[1]到topic[3]存储了带indexed变量的keccak256哈希。

我们可以在函数里释放事件。在下面的例子中,每次用_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);
    }

继承

如果把合约看作是对象的话,solidity也是面向对象的编程,也支持继承。

规则​

抽象合约和接口

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

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

接口​

接口类似于抽象合约,但它不实现任何功能。接口的规则:

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

另外,接口与合约ABI(Application Binary Interface)等价,可以相互转换:编译接口可以得到合约的ABI, 利用abi-to-sol工具也可以将ABI json文件转换为接口sol文件。 我们以ERC721接口合约IERC721为例,它定义了3个event和9个function,所有ERC721标准的NFT都实现了这些函数。 我们可以看到,接口和常规合约的区别在于每个函数都以;代替函数体{ }结尾。

异常

Error​

error是solidity 0.8版本新加的内容,方便且高效(省gas)地向用户解释操作失败的原因。人们可以在contract之外定义异常。 下面,我们定义一个TransferNotOwner异常,当用户不是代币owner的时候尝试转账,会抛出错误:

error TransferNotOwner(); // 自定义

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

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

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

Require​

require命令是solidity 0.8版本之前抛出异常的常用方法,目前很多主流合约仍然还在使用它。它很好用,唯一的缺点就是gas随着描述异常的字符串长度增加,比error命令要高。使用方法:require(检查条件,”异常的描述”),当检查条件不成立的时候,就会抛出异常。 我们用require命令重写一下上面的transferOwner函数: function transferOwner2(uint256 tokenId, address newOwner) public { require(_owners[tokenId] == msg.sender, “Transfer Not Owner”); _owners[tokenId] = newOwner; }

Assert​

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

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

三种方法的gas比较​

我们比较一下三种抛出异常的gas消耗,通过remix控制台的Debug按钮,能查到每次函数调用的gas消耗分别如下: