Solidity 基础

智能合约开发 03

数据类型:值类型

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 不仅可以使用其自己定义的 spendingAmountspendMoney,还可以访问 Parent 的 moneygetMoney

函数重写

如果子合约需要修改从父合约继承的行为,可以通过重写函数来实现。

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);
    }
    // 只有所有者可以执行以下操作
}