Solidity 基础
数据类型:值类型
Solidity 具有静态类型,所以每个变量的类型在编译时必须被指定出来,下面是一些基本的 Solidity 变量类型:
- unit:无符号整数,只能存储非负值。
- int:有符号整数,可以存储负数和正数。
- string:存储字符串。
- bool:true 和 false。
- address:存储以太坊地址。
- enum:用于创建自定义类型,限定在一组命名的常量之间。
声明变量的基本语法:type variableName;
示例
uint storeNumber; // 声明一个无符号整数变量
string favoriteBook; // 声明一个字符串变量
bool isOpen; // 声明一个布尔变量
address userAddress; // 声明一个地址变量
enum State { Created, Locked, Inactive }
实践
打开 Remix IDE,创建一个新的合约文件 MyFirstContract.sol
,输入以下代码:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;
contract MyFirstContract {
uint public mySecretNumber;
function storeNumber(uint _number) public {
mySecretNumber = _number;
}
function retrieveNumber() public view returns (uint) {
return mySecretNumber;
}
}
在这个合约里,我们声明了一个公共变量 mySecretNumber,并提供了两个函数:一个用于存储数字,另一个用于检索它。
在 Remix 上保存,编译。
函数
1. 函数是什么
函数是执行特定任务的代码块,可以接收输入,处理数据,并可选择返回输出。函数是智能合约的基本组成部分,用于定义和执行合约的行为。
2. 一个基本的函数声明包括:
- 函数名
- 参数列表
- 可见性修饰符(
public
;private
) - 返回类型(可选)
例如:
function functionName(params) public returns (returnType) {
// 函数体
}
3. 函数类型
- view(视图函数):用于读取状态变量而不修改它们
- pure(纯函数):不读取也不修改状态变量。
- payable(支付函数):可以接收以太坊。
4. 可见性修饰符
-
public:函数可以在任何地方被访问,包括可以被其他合约和交易被访问。(使用场景:想让这个函数被用户或其他合约调用时使用)
-
private:限制函数只能在定义它的合约内部被访问,即使是由该合约派生的子合约也无法访问。(使用场景:处理敏感数据或逻辑时使用)
-
internal:函数只能在合约内部被访问,并且可以被继承该合约的派生合约访问。
-
external:只能从合约外部被调用,这意味着它们只能通过交易来被访问,不能通过内部的方式调用(即,这些函数不能被合约内的其他函数直接调用,除非通过 this.functionName() 的形式)。(使用场景:函数需要从外部调用,并且你希望它们不占用额外的 gas(因为 external 函数比 public 函数在某些情况下能节省更多 gas),那么使用 external 修饰符是合适的。)
不仅仅是函数,变量也可以设置可见性。
示例:
打开 Remix IDE,并继续使用 MyFirstContract.sol
合约,添加一些函数:
// 添加一个 view 函数来读取秘密数字
function getSecretNumber() public view returns(uint) {
return mySecretNumber;
}
// 添加一个普通函数来修改秘密数字
function setSecretNumber(uint newNumber) public {
mySecretNumber = newNumber;
}
// 添加一个 pure 函数,计算两个数字的和,而不更改任何状态变量
function addNumbers(uint a, uint b) public pure returns (uint) {
return a + b;
}
控制结构
1. 什么是控制结构
允许我们在程序中引入决策点,基于特定条件执行不同的代码段。
在 Solidity 中,主要的控制结构包括:
- 条件语句(if, else)
- 循环结构(for, while, do while)
- 条件运算符(? :)
2. 条件语句
if
语句:根据条件的真假,执行不同的代码块。
if (condition) {
// 条件为真时,执行的代码
} else {
// 条件为假时,执行的代码
}
3. 循环
for 循环:用于在知道循环次数时使用。
for (uint i = 0; i < 10; i++) {
// 循环体,将执行10次
}
while 循环:当不确定循环次数,但有一个持续的条件时使用。
while (condition) {
// 只要条件为真就持续执行
}
do while 循环:至少执行一次,然后再检查条件。
do {
// 至少执行一次的代码
} while (condition);
示例
继续使用 MyFirstContract.sol
这个合约,我们将添加一个功能,来处理不同的投票选项。
首先,添加一些基本的状态变量来存储投票结果,这里我们先不使用数组,只用三个整数来记录三个选项的票数。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;
contract MyFirstContract {
uint public votesOptionOne;
uint public votesOptionTwo;
uint public votesOptionThree;
}
然后,创建一个简单的投票函数,使用 if
来处理不同的投票选项。
function vote(uint option) public {
uint count = 0;
while(count < 50) {
if (option == 1) {
votesOptionOne++;
} else if (option == 2) {
votesOptionTwo++;
} else if (option == 3) {
votesOptionThree++;
}
}
}
最后,添加一个使用 if
的函数来确定哪个选项获得了最多的票数。
function winningOption() public view returns (uint) {
if (votesOptionOne > votesOptionTwo && votesOptionOne > votesOptionThree) {
return 1;
} else if (votesOptionTwo > votesOptionThree) {
return 2;
} else {
return 3;
}
}
数据类型:引用类型
1. 数组
数组有两种,固定数组和动态数组:
uint[3] fixedArray; // 固定数组
uint[] dynamicArray; // 动态数组
2. 结构体
结构体(struct)是允许定义拥有多个属性的新类型:
struct Person {
string name;
uint age;
}
3. 映射
映射(mapping)是键值对的集合,类似于字典或哈希表:
mapping(address => uint) public balances
↑,定义了一个公共的 balances 变量,把地址(address)和无符号整数(uint,余额)之间建立了一个映射关系。
使用:
balances[address] = amount; // 存储余额
uint balance = balances[address]; // 检索指定地址的余额,并赋值给 balance
balances[address] += amount; // 更新余额
delete balances[address]; // 删除余额
存储位置
引用类型需要指定数据的存储位置,主要是 memory(临时存储,主要用于方法内部)和 storage(永久存储,状态变量默认使用)。
memory:临时存储位置。在函数执行期间,变量在 memory 中分配空间,并在函数执行完毕后释放。当你在函数内部声明一个引用类型的变量时(如数组、结构体等),它默认会被存储在 memory 中。这意味着变量的生命周期仅限于函数的执行期间,并且在函数外部无法访问。
storage:永久存储位置,用于存储状态变量,即合约的成员变量。storage 中的数据在合约的整个生命周期中都保持存在。当你在合约中声明一个引用类型的状态变量时,它默认会被存储在 storage 中。这意味着该变量可以被合约内的任何函数访问,并且对该变量的更改会永久保存在区块链上。
在 storage 中的操作,可能会消耗较多的 Gas,因此在设计合约时需要考虑到数据存储和访问的效率。
gas 成本:对于引用类型的操作,尤其是在 storage 中的操作,可能会消耗较多的 Gas。这是因为在存储上链时,需要记录数据的变动,并在区块链上写入相应的状态。因此,在设计合约时,需要谨慎考虑数据存储和访问的频率,以避免不必要的高 Gas 消耗。
数据存储和访问效率:由于在 storage 中的数据是永久存储的,因此对于频繁访问的数据,将其存储在 storage 中可能会更有效率。然而,如果数据仅在函数内部使用,并且不需要在函数之间共享,将其存储在 memory 中可能更为合适。这样可以避免重复存储和访问 storage 中的数据,从而提高合约的执行效率。
继承
继承是面向对象编程中的一个核心概念,它允许一个合约继承另一个合约的方法和变量。在 Solidity 中,继承是通过使用关键字 is
实现的。
contract Parent {
uint public money = 100;
function getMoney() public view returns (uint) {
return money;
}
}
contract Child is Parent {
uint public spendingAmount = 10;
function spendMoney(uint amount) public {
money -= amount;
}
}
Child 不仅可以使用其自己定义的 spendingAmount
和 spendMoney
,还可以访问 Parent 的 money
和 getMoney
。
函数重写
如果子合约需要修改从父合约继承的行为,可以通过重写函数来实现。
contract Parent {
function greet() public pure returns (string memory) {
return "Hello from Parent";
}
}
contract Child is Parent {
function greet() public pure override returns (string memory) {
return "Hello from Child";
}
}
多重继承
Solidity 也支持多重继承,即一个合约可以继承多个合约。继承的顺序很重要,因为它影响到了函数重写的优先级。
contract A {
function foo() public pure returns (string memory) {
return "A";
}
}
contract B {
function foo() public pure returns (string memory) {
return "B";
}
}
contract C is A, B {
function foo() public pure override(A, B) returns (string memory) {
return super.foo(); // Calls B's foo because B is the last parent
}
}
接口
1. 接口定义
接口在 Solidity 中是一种特殊的合约,用来声明函数但不实现它们(只有函数声明,没有函数体)。接口可以包含事件声明、函数声明以及变量声明。
interface IPayment {
function pay(address recipient, uint256 amount) external;
function balanceOf(address account) external view returns (uint256);
}
在这个例子中,IPayment 接口声明了两个函数:pay 和 balanceOf。任何实现此接口的合约都必须提供这些函数的具体实现。
2. 接口实现
合约通过使用关键词 is
来实现一个或多个接口。
contract PaymentProcessor is IPayment {
mapping(address => uint256) private balances;
function pay(address recipient, uint256 amount) external override {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
balances[recipient] += amount;
}
function balanceOf(address account) external view override returns (uint256) {
return balances[account];
}
}
这里,PaymentProcessor 合约实现了 IPayment 接口,提供了 pay 和 balanceOf 函数的具体实现。注意,我们使用 override 关键字来明确指出这些函数是从接口继承的。
3. 接口的用途
-
解耦合: 接口帮助我们将合约的定义与实现分离,提高合约系统的灵活性和可维护性。
-
互操作性: 接口使得不同的合约可以安全地调用其他合约的函数,即使它们被部署在不同的时间。
-
标准化: 接口可以作为在区块链项目中实现标准化合约的工具,例如 ERC20 代币标准。
错误处理机制
Solidity 提供了几种不同的错误处理方法,可以在合约执行出现问题时恢复或回滚状态,包括:
1. require 函数
用于验证条件,如果条件不满足,则撤销状态变更并退回所有剩余的 Gas。常用于输入验证或合约前置条件检查。
function withdraw(uint amount) public {
require(amount <= balances[msg.sender], "Insufficient balance");
balances[msg.sender] -= amount;
msg.sender.transfer(amount);
}
2. assert 函数
用于内部错误和检查不变量,仅在代码有逻辑错误时失败。使用 assert 而不是 require 是非常罕见的,因为 assert 失败会消耗所有剩余 Gas。
function divide(uint a, uint b) public pure returns (uint) {
assert(b > 0); // 应永远为真,除非有严重错误
return a / b;
}
3. revert 函数
与 require 类似,revert 允许在错误发生时提供更详细的错误信息。
function failFunction() public {
revert("Failure reason");
}
4. 自定义错误
自定义错误是一种更加节约 gas 的方式来抛出错误。自定义错误允许你定义一个错误类型,然后使用 revert 与之关联来抛出错误。
error Unauthorized(address caller);
function restrictedAccess() public {
if (msg.sender != owner) {
revert Unauthorized(msg.sender);
}
// 只有所有者可以执行以下操作
}