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. 值类型
booleanfalse,
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、多签钱包、代币锁、时间锁、空投发放、以及实战项目解析等。敬请期待 (^▽^)