1. Hello World
直接在remix网页版进行测试,编写代码,部署:
pragma solidity ^0.8.21;
contract HelloWorld{
string public _string = "Hello Web3!";
}
2. 变量类型
1. 值类型
1. 布尔
bool 类型只有两个值,true或者false
bool public _bool = true;
2. 整型
整型分为有符号的和无符号的,分别用int
和uint
表示
如:
int public _int = -1; // 整数,包括负数
uint public _uint = 1; // 无符号整数
uint256 public _number = 20220330; // 256位无符号整数
3. 地址
solidity中有一种特殊的值类型,称为地址类型。在以太坊中,地址类型为一个20字节的值。
address public _address = 0x7A58c0Be72BE218B41C608b7Fe7C5bB630736C71;
address payable public _address1 = payable(_address); // payable address,可以转账、查余额
其中payable
地址表示该地址比普通地址多了transfer
send
两个成员方法,用于转账。
4. 定长数组
例如bytes1
和 bytes8
分别表示一个字节长度(8bit
)和8个字节长度。定长数组最多可存储32 bytes的数据。
bytes32 public _byte32 = "fix length bytes";
bytes1 public _byte = _byte32[0];
5. 枚举
枚举enum
是用户自定义的数据类型。底层实际上是uint
,便于程序的维护。
// 用enum将uint 0, 1, 2表示为TOM、BILLY、JACK
enum NameSet {
TOM,
BILY,
JACK
}
// 创建enum变量 names
NameSet names = NameSet.TOM;
2. 引用类型
1. 可变长数组
可变长数组和定长数组在写法上的唯一区别就是:可变长数组不需要写长度,而定长数组需要写长度。
// 固定长度 Array
uint[8] array1;
bytes1[5] array2;
// 可变长度 Array
uint[] array3;
bytes1[] array4;
对于动态数组,如果是memory
修饰的,可以使用new
操作符来创建,但是必须声明长度,且长度不能改变。
function testArray() public pure returns (uint[] memory, bytes memory) {
// 动态数组
uint[] memory array5 = new uint[](5);
bytes memory array6 = new bytes(9);
return (array5,array6);
}
数组成员有:length
、push()
、pop()
分别可以获取数组的长度、放入元素、弹出元素。
function testFunc() public returns(uint) {
array3.push(1);
array3.push(2);
array3.pop();
return array3.length;
}
2. 结构体
结构体可以用来自定义新的数据类型,属于引用类型的一种。
// 结构体
struct Student {
uint256 id;
uint256 score;
}
Student student; // 初始一个student结构体
// 方法1:单个赋值
function initStudent2() external {
student.id = 1;
student.score = 80;
}
// 方法2:联合赋值
function initStudent3() external {
student = Student(3, 90);
}
// 方法3:对象格式
function initStudent4() external {
student = Student({id: 4, score: 60});
}
function getStudent() external returns(Student memory) {
student = Student(3, 90);
return student;
}
3. Mapping映射
mapping
在solidity
中,可以将其类比与其他语言中的map,属于哈希表的结构。
mapping(uint => Student) public idToAddress; // id映射到地址
function writeMap(uint _Key, Student memory _Value) public {
idToAddress[_Key] = _Value;
}
- 在
mapping
中,key
只能是solidity
的内部数据类型,而value
可以是我们自定义的结构体类型。 - 对于
public
作用域的mapping
而言,solidity
会自动给我们创建一个getter()
。可以用来通过key
查询value
。
3. 变量的作用域
1. 数据的位置
storage
: 数据存在区块链上,消耗 gas
较多
string[] public items;
// storage:修改链上状态
function useStorage() public returns(uint) {
string[] storage s = items;
s.push("storage");
return items.length;
}
memory
:数据存在临时内存上,消耗 gas
较少
// memory:只读临时变量
function useMemory() public view returns (string memory) {
string[] memory m = items;
return m[0];
}
calldata
:相比于memory
而言,calldata
不可修改,一般用于函数参数,消耗gas最少。
// calldata:external 参数,省 gas,只读
function useCalldata(string[] calldata input) external pure returns (string calldata) {
return input[0];
}
2. 数据的生命周期
状态变量:状态变量是存储在链上的变量,在函数外声明即为状态变量。
contract Variables {
uint public x = 1;
}
局部变量:局部变量是存储在内存的变量,函数退出后销毁,不上链,gas消耗低。
function local() external{
// 可以在函数里更改状态变量的值
x = 1;
}
全局变量:全局变量属于solidity
的预留关键字,可以不声明直接使用。msg.sender
、block.number
、msg.data
等都属于我们常用全局变量。
function global() external view returns(address){
address sender = msg.sender;
return(sender);
}
4. 变量的初始值
1. 值类型
boolean
false,
string
:""
int
: 0
uint
: 0
enum
: 枚举的第一个元素
address
:address(0)
function
:空白函数
2. 引用类型
mapping
:所有元素默认值的mapping
struct
:所有成员变量默认值的结构体
静态数组
:成员默认值的数组,如:[0,0,0,0]
动态数组
:[]
5. 常量和不变量
1. constant
常量必须在声明时初始化。
uint256 constant CONSTANT_NUM = 10;
2. immutable
不可变量不需要显式初始化。但是在第一次初始化之后就无法再进行改变了。相对于常量而言更加灵活。
address public immutable IMMUTABLE_ADDRESS;
特例:immutable
如果显式声明,但在 constructor()
中又再次初始化,会采用 constructor()
中的。
address public immutable IMMUTABLE_ADDRESS = address(0);
constructor(){
// 构造器中初始化是允许的,不会报错
IMMUTABLE_ADDRESS = address(this);
}
再次初始化会报错
6. 函数
1. pure 和 view
pure
和 view
修饰状态变量的访问权限。
- pure
表明该方法是一个存函数,不读也不写状态变量。gas
费最低。
- view
代表只读,但不能修改状态变量。
function add(uint a, uint b) public pure returns (uint) {
return a + b; // 纯计算
}
function getCount() public view returns (uint) {
return count; // 只读状态变量
}
function setCount(uint x) public {
count = x; // 修改状态变量
}
2. internal 和 external
internal
和 external
控制函数的可见性。
- internal
只能内部合约,或者继承进行调用。
- external
只能外部合约调用。
contract Example {
function pubFunc() public {} // 内外都能调
function extFunc() external {} // 只能外部调
function intFunc() internal {} // 只能合约内部/继承使用
function privFunc() private {} // 只能本合约用
}
3. private 和 public
private
和 public
控制函数的可见性。
- private
只允许合约内部进行调用。
- public
允许合约内部、合约外部都能调用。
函数可见性控制表格对比:
| 可见性关键字 | 合约外部可调用 | 合约内部可调用 | 子合约可调用 | 说明 |
|--------------|----------------|----------------|---------------|------|
| public
| ✅ 是 | ✅ 是 | ✅ 是 | 内外都能访问 |
| external
| ✅ 是 | ❌ 否(除非用 this.
) | ✅ 是 | 只能外部调用,gas 更省 |
| internal
| ❌ 否 | ✅ 是 | ✅ 是 | 只能内部或继承调用 |
| private
| ❌ 否 | ✅ 是 | ❌ 否 | 仅当前合约内部可见 |
4. payable
允许调用时候附带ETH
function payMe() public payable {
// 接收 ETH
7. 函数输出
1. return 和returns
return
:return
用于在函数内返回,可以返回单个也可以返回多个。 -returns
:returns
用于在函数声明时,声明函数的返回个数、类型。
// 返回一个整数:returns 声明返回类型,return 返回值
function getNumber() public pure returns (uint256) {
return 42;
}
// 返回多个值
function getTwoValues() public pure returns (uint256, string memory) {
return (1, "hello");
}
2. 声明式返回
可以将返回值的定义写在方法声明上,在函数内无需手动return
// 声明式返回
function returnNamed() public pure returns(uint256 _number, bool _bool){
_number = 2;
_bool = false;
}
8. 程序控制流
1. if-else
function ifElseTest(uint256 _number) public pure returns(bool){
if(_number == 0){
return(true);
}else{
return(false);
}
}
2. for循环
function forLoopTest() public pure returns(uint256){
uint sum = 0;
for(uint i = 0; i < 10; i++){
sum += i;
}
return(sum);
}
3. while循环
function forLoopTest() public pure returns(uint256){
uint sum = 0;
for(uint i = 0; i < 10; i++){
sum += i;
}
return(sum);
}
4. do-while循环
function doWhileTest() public pure returns(uint256){
uint sum = 0;
uint i = 0;
do{
sum += i;
i++;
}while(i < 10);
return(sum);
}
5. 三目运算符
// 三目运算符
function ternaryTest(uint256 x, uint256 y) public pure returns(uint256){
// return the max of x and y
return x >= y ? x: y;
}
9. 构造函数
- 构造函数会在合约部署时,自动运行一次。可以用来初始化合约的一些参数。
- 构造函数的函数名必须为
constructor()
address owner; // 定义owner变量
// 构造函数
constructor(address initialOwner) {
owner = initialOwner; // 在部署合约的时候,将owner设置为传入的initialOwner地址
}
合约部署时,传入地址,表示这个合约的归属权属于这个地址。
10. 修饰器
modifier
可以声明一段代码。其他函数使用这个修饰器可以添加一些公共的逻辑。例如做运行函数前的地址、变量、余额检查。
- 声明 modifier
:在声明时,定义一些通用代码片段,_;
表示运行程序的主体。
// 定义modifier
modifier onlyOwner {
require(msg.sender == owner); // 检查调用者是否为owner地址
_; // 如果是的话,继续运行函数主体;否则报错并revert交易
}
- 使用
modifier
:使用只需要在函数声明时,添加上modifier
的名称即可使用。
function changeOwner(address _newOwner) external onlyOwner{
owner = _newOwner; // 只有owner地址运行这个函数,并改变owner
}
非owner
调用会失败,因为合约的归属权属于owner
,只有他才能调用转让归属权。
11. 事件
solidity
中的事件(event
)是以太坊虚拟机上日志的抽象。通过发布事件,别的应用程序可以订阅这个事件,做到实时响应。- 事件相对比与链上变量存储而言,消耗的
gas
较低。每个大概消耗2,000 gas;相比之下,链上存储一个新变量至少需要20,000 gas。
1. 事件的声明
用 event
关键字来声明事件。如:
event Transfer(address indexed from, address indexed to, uint256 value);
在这个事件中,定义了转账的 from
地址 to
地址 和 amount
金额。其中 from
和 to
前面有 indexed
关键字。 带有这个关键字的属性,会保存在以太坊虚拟机日志 topics
中,方便检索。
2. 事件的释放
定义好事件之后,在合约中,可以调用 emit
关键字来进行事件的发布。如:
// 定义_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);
}
3. 事件的主题topic
在以太坊的虚拟机中,使用 Log
来存储 solidity
的时间。每条日志记录了 topic
和 data
。
- Topic
如图可看出,在0的位置,是一个事件的
hash
。 而在1 和2 的位置,存储的就是我们填写的indexed
后的from
地址和to
地址。 indexed
标记的参数可以理解为检索事件的索引“键”,方便之后搜索。每个indexed
参数的大小为固定的256
比特,如果参数太大了(比如字符串),就会自动计算hash
存储在主题中。
4. 事件的数据data
事件中不带 indexed
的参数会被存储在 data
部分中,可以理解为事件的“值”。data
部分的变量不能被直接检索,但可以存储任意大小的数据。因此一般 data
部分可以用来存储复杂的数据结构,例如数组和字符串等等,因为这些数据超过了 256
比特,即使存储在事件的 topics
部分中,也是以哈希的方式存储。另外,data
部分的变量在存储上消耗的 gas
相比于 topics
更少。
12. 错误与异常
1. error
0.8.4
新增内容,方便且省 gas
。
定义: 采用 error
来自定义异常。
error TransferNotOwner(address sender); // 自定义的带参数的error
捕获: 配合 revert
来抛出异常并回滚。
function transferOwner1(uint256 tokenId, address newOwner) public {
if(_owners[tokenId] != msg.sender){
revert TransferNotOwner(msg.sender);
}
_owners[tokenId] = newOwner;
}
检查 owner
是否发起人, 如果不是,通过 revert 来抛出异常。
2. require
require
命令是旧版本抛出异常的常用方法,目前很多主流合约仍然还在使用它。它很好用,唯一的缺点就是 gas
随着字符串长度增加,比error命令要高。
function transferOwner2(uint256 tokenId, address newOwner) public {
require(_owners[tokenId] == msg.sender, "Transfer Not Owner");
_owners[tokenId] = newOwner;
}
require
使用比较简单,无需定义异常,只需要传入判断,以及拼接要抛出的字符串即可。但是 gas
消耗可能会比较高。
3. assert
assert
命令一般用于 debug
。一般不用于生产,因为没法解释异常的原因。使用上和 require
类似,不传入字符串即可。
function transferOwner3(uint256 tokenId, address newOwner) public {
assert(_owners[tokenId] == msg.sender);
_owners[tokenId] = newOwner;
}
4.gas 费对比
error < assert < require !!!
13. 继承
solidity
支持继承,包括简单继承、多重继承、修饰器继承、构造器继承。如果父合约希望子合约继承,需要加上 virtual
关键字,子合约实现需要加上 override
关键字。
mapping(address => uint256) public override balanceOf;
1. 简单继承
// 父合约(基类)
contract Animal {
string public name;
constructor(string memory _name) {
name = _name;
}
function speak() public view virtual returns (string memory) {
return "Animal sound";
}
}
// 子合约(继承 Animal)
contract Dog is Animal {
constructor() Animal("Dog") {}
// 重写函数
function speak() public pure override returns (string memory) {
return "Woof!";
}
}
2. 多重继承
solidity
支持多重继承
- 继承时按从高到低排列,例如:狗继承于动物和生物。需要协程 狗 is 生物, 动物
而不能是 狗 is 动物, 生物
- 如果一个函数在多个父合约都存在,子合约中必须重写,否则会报错。
- 重写多个父合约中重名的函数,override
后面加上所有父合约的名字,如 override(生物,动物)
// 基类 1:Animal
contract Animal {
function speak() public pure virtual returns (string memory) {
return "Animal sound";
}
}
// 基类 2:Pet
contract Pet {
function play() public pure virtual returns (string memory) {
return "Playing like a pet";
}
}
// 子类:Dog 同时继承 Animal 和 Pet
contract Dog is Animal, Pet {
// 重写 Animal 的 speak()
function speak() public pure override returns (string memory) {
return "Woof!";
}
// 重写 Pet 的 play()
function play() public pure override returns (string memory) {
return "Dog is playing";
}
}
3. 修饰器继承
修饰器 modifier
也可以继承。
contract Ownable {
address owner = msg.sender;
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
}
contract MyContract is Ownable {
uint secret;
function setSecret(uint _s) public onlyOwner {
secret = _s;
}
}
子合约 MyContract
通过 继承 父合约 Ownable
进而获得父合约中的 修饰器 onlyOwner()
,在子合约中就可以直接使用了。
4. 构造函数继承
同理,构造函数也能继承。
contract A {
uint public x;
constructor(uint _x) {
x = _x;
}
}
contract B is A {
constructor() A(42) {}
}
在子合约的构造函数声明中,直接通过函数式调用A(42)
即可实现对父函数构造函数的调用。
5. 父合约调用
子合约中,如果需要调用父合约的函数。有两种方式,分别是:super
、合约名
。如下:
contract A {
function sayHello() public returns(string memory) {
return "hello";
}
}
contract B is A {
function callFather() public returns(string memory) {
// return A.sayHello();
return super.sayHello();
}
}
6. 钻石继承
钻石继承(菱形继承),指一个子类有两个或者两个以上父类,形成菱形的结构。
/* 继承树:
A
/ \
B C
\ /
D
*/
钻石继承的调用链有两种: 1. 最近截断:
contract A {
function greet() public pure virtual returns (string memory) {
return "Hello from A";
}
}
contract B is A {
function greet() public pure virtual override returns (string memory) {
return "Hello from B";
}
}
contract C is A {
function greet() public pure virtual override returns (string memory) {
return "Hello from C";
}
}
contract D is B, C {
function greet() public pure override(B, C) returns (string memory) {
return super.greet();
}
}
如图,在C和B没有手动调用 super
的情况下,会按照D的继承的 override(B,C)
去找到最后一个C去调用。
- 往上查找:
event CallTrace(string log);
contract A {
function greet() public virtual {
emit CallTrace("call A");
}
}
contract B is A {
function greet() public virtual override {
super.greet();
emit CallTrace("call B");
}
}
contract C is A {
function greet() public virtual override {
super.greet();
emit CallTrace("call C");
}
}
contract D is B, C {
// 必须显式指定 override(B, C),否则报错
function greet() public override(B, C) {
super.greet();
emit CallTrace("call D");
// 日志输出:A->B->C->D,即D从C找到B再找到A。
}
}
可以看到,由于我们指定了override(B, C)
,所以在这种继承树的查找中,会从D找到C,再找到B,再找到A。所以输出的顺序为 A->B->C->D
。
14. 抽象和接口
1. 抽象合约
如果一个父合约中含有未实现的函数,我们将其成为抽象合约。抽象合约必须被标记为abstract
并且函数需要声明virtual
。
abstract contract AbstractA {
function sayHello() public virtual;
}
contract InplementA is AbstractA {
function sayHello() public virtual override {
// do something
}
}
2. 接口
接口和抽象合约有点类似,只不过接口更关注的是 ”拥有的能力“。
- 接口不能包含状态变量
- 接口不能包含构造函数
- 接口不能继承除接口外的合约
- 所有函数必须是 external
- 接口的函数必须被完全实现
interface ISpeak {
function sayHello() external;
}
contract Human is ISpeak {
function sayHello() external override {
// do something
}
}
15. 函数重载
1. 什么是重载
函数重载指的是:函数名相同,但是输入参数类型不同的函数可以同时存在。注意:修饰器(modifier
)不能重载。
contract Human {
function sayHello() external returns(string memory) {
return "hello";
}
function sayHello(string memory language ) external returns(string memory) {
return language;
}
}
2. 实参匹配
在 solidity 中,由于一些变量有多种类型。例如 int
类型 有 uint8
uint256
等。当函数名一样,参数类型大致也一样,但一个是 uint8
一个是 uint256
时,会报错。因为一个 number
既能转成 uint8
也能转成 uint256
,编译器拒绝这样的重载。
function f(uint8 _in) public pure returns (uint8 out) {
out = _in;
}
function f(uint256 _in) public pure returns (uint256 out) {
out = _in;
}
16. 库合约
编程语言上,站在巨人的肩膀上无疑是走得最快的,solidity
也不例外。库合约是别人写好的函数集。和普通的合约有一些区别:
- 不能存状态变量
- 不能继承或被继承
- 不能接受以太币
- 不可以被销毁
以 Strings 库合约为例:
import "@openzeppelin/contracts/utils/Strings.sol";
contract MyContract {
using Strings for uint256; // ✅ 给 uint256 加上扩展方法
function test() public pure returns (string memory) {
uint256 num = 123;
return num.toString(); // 使用 Strings 库的 toString()
}
}
常用库合约:
1. strings
:将 uint256 转成 String
2. Address
:判断地址是否为合约地址
3. Create2
:更安全的使用 Create2 EVM opcode
4. Arrays
:和数组有关的库合约
17. 外部合约引入
外部合约的引入有很多种方式,和库合约的引入类似,都是让我们站在巨人的肩膀上。 - 源文件引入:
import './Hello.sol';
- 全局符号引入:
import {Hello} from './Hello.sol';
- 网址引入:
import 'https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/Address.sol';
- npm引入:
import '@openzeppelin/contracts/access/Ownable.sol';
我们以npm引入为例:
import "@openzeppelin/contracts/access/Ownable.sol";
contract MyOwnable is Ownable {
string public message;
constructor(address initialOwner) Ownable(initialOwner) {
// 显式调用父类构造函数
}
function setMessage(string calldata _msg) external onlyOwner {
message = _msg;
}
}
可以看出,我们使用了 Ownable
的外部合约中的修饰器,限制了我们 setMessage
方法的权限。
🤡:自此,solidity
的基础语法已讲解完毕,后面的文章将会开启 solidity
的进阶和拔高教程。包括发送ETH、接收ETH、调用合约、ABI编解码、合约升级、数字签名、ERC20、ERC721、ERC1155、多签钱包、代币锁、时间锁、空投发放、以及实战项目解析等。敬请期待 (^▽^)