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。
- public: 内部外部均可见。(也可用于修饰状态变量,public变量会自动生成 getter函数,用于查询数值).
- private: 只能从本合约内部访问,继承的合约也不能用(也可用于修饰状态变量)。
- external: 只能从合约外部访问(但是可以用this.f()来调用,f是函数名)
- internal: 只能从合约内部访问,继承的合约可以用(也可用于修饰状态变量)。
[pure|view|payable]:决定函数权限/功能的关键字。payable(可支付的)很好理解,带着它的函数,运行的时候可以给合约转入ETH。 合约中非pure/view函数调用它们则会改写链上状态,需要付gas,声明pure/view的函数不会改链上状态 [returns ()]:函数返回的变量类型和名称。
在以太坊中,以下语句被视为修改链上状态:
写入状态变量。 释放事件。 创建其他合同。 使用selfdestruct. 通过调用发送以太币。 调用任何未标记view或pure的函数。 使用低级调用(low-level calls)。 使用包含某些操作码的内联汇编。
函数输出
返回值return和returns
Solidity有两个关键字与函数输出相关:return和returns
,他们的区别在于:
- returns加在函数名后面,用于声明返回的变量类型及变量名;
- return用于函数主体中,返回指定的变量。
// 返回多个变量
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);
}
赋值规则
- storage(合约的状态变量)赋值给本地storage(函数里的)时候,会创建引用,改变新变量会影响原变量
- storage赋值给memory,会创建独立的复本,修改其中一个不会影响另一个;反之亦然。
- memory赋值给memory,会创建引用,改变新变量会影响原变量。
- 其他情况,变量赋值给storage,会创建独立的复本,修改其中一个不会影响另一个。
变量的作用域
Solidity中变量按作用域划分有三种,分别是状态变量(state variable),局部变量(local variable)和全局变量(global variable)
- 状态变量是数据存储在链上的变量,所有合约内函数都可以访问 ,gas消耗高。
- 局部变量是仅在函数执行过程中有效的变量,函数退出后,变量无效。局部变量的数据存储在内存里,不上链,gas低。
- 全局变量是全局范围工作的变量,都是solidity预留关键字。他们可以在函数内不声明直接使用
变量的初始值
值类型初始值
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);
数组成员
- length: 数组有一个包含元素数量的length成员,memory数组的长度在创建后是固定的。
- push(): 动态数组和bytes拥有push()成员,可以在数组最后添加一个0元素。
- push(x): 动态数组和bytes拥有push(x)成员,可以在数组最后添加一个x元素。
- pop(): 动态数组和bytes拥有pop()成员,可以移除数组最后一个元素。
结构体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也是面向对象的编程,也支持继承。
规则
- virtual: 父合约中的函数,如果希望子合约重写,需要加上virtual关键字。
- override:子合约重写了父合约中的函数,需要加上override关键字。
多重继承
继承时要按辈分最高到最低的顺序排。比如我们写一个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"); }
我们可以看到,Erzi合约里面重写了hip()和pop()两个函数,将输出改为”Erzi”,并且还分别从Yeye和Baba合约继承了yeye()和baba()两个函数。
修饰器的继承
Solidity中的修饰器(Modifier)同样可以继承,用法与函数继承类似,在相应的地方加virtual和override关键字即可。 构造函数的继承 子合约有两种方法继承父合约的构造函数。举个简单的例子,父合约A里面有一个状态变量a,并由构造函数的参数来确定:
// 构造函数的继承 abstract contract A { uint public a; constructor(uint _a) { a = _a; } }
在继承时声明父构造函数的参数,例如:contract B is A(1) 在子合约的构造函数中声明构造函数的参数,例如:
contract C is A { constructor(uint _c) A(_c * _c) {} }
调用父合约的函数
子合约有两种方式调用父合约的函数,直接调用和利用super关键字。 直接调用:子合约可以直接用父合约名.函数名()的方式来调用父合约函数,例如Yeye.pop()。
function callParent() public{ Yeye.pop(); }
super关键字:子合约可以利用super.函数名()来调用最近的父合约函数。solidity继承关系按声明时从右到左的顺序是:contract Erzi is Yeye, Baba,那么Baba是最近的父合约,super.pop()将调用Baba.pop()而不是Yeye.pop():
function callParentSuper() public{ // 将调用最近的父合约函数,Baba.pop() super.pop(); }
抽象合约和接口
如果一个智能合约里至少有一个未实现的函数,即某个函数缺少主体{}中的内容,则必须将该合约标为abstract,不然编译会报错;另外,未实现的函数需要加virtual,以便子合约重写。
abstract contract InsertionSort{
function insertionSort(uint[] memory a) public pure virtual returns(uint[] memory);
}
接口
接口类似于抽象合约,但它不实现任何功能。接口的规则:
- 不能包含状态变量
- 不能包含构造函数
- 不能继承除接口外的其他合约
- 所有函数都必须是external且不能有函数体
- 继承接口的合约必须实现接口定义的所有功能
虽然接口不实现任何功能,但它非常重要。接口是智能合约的骨架,定义了合约的功能以及如何触发它们: 如果智能合约实现了某种接口(比如ERC20或ERC721),其他Dapps和智能合约就知道如何与它交互。 因为接口提供了两个重要的信息:
- 合约里每个函数的bytes4选择器,以及基于它们的函数签名函数名(每个参数类型)。
- 接口id(更多信息见EIP165)
另外,接口与合约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消耗分别如下:
- error方法gas消耗:24445
- require方法gas消耗:24743
- assert方法gas消耗:24446 我们可以看到,error方法gas最少,其次是assert,require方法消耗gas最多!因此,error既可以告知用户抛出异常的原因,又能省gas,大家要多用!(注意,由于部署测试时间的不同,每个函数的gas消耗会有所不同,但是比较结果会是一致的。