Solidity入门(4)-合约及其组成结构

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档

文章目录


前言

在 Solidity 中,合约类似于其他编程语言中的类(class),它拥有自己的成员属性和成员函数。例如,一个去中心化交易所可以被实现为一个合约,借贷平台的功能也可以通过合约来实现。你可以使用 contract 关键字来定义一个新的合约:

bash 复制代码
contract myContractName {
    _// members_
}

合约组成结构


下图展示了合约的组成结构:

在 Solidity 中,合约由多个组成部分构成,每个部分承担不同的角色和功能,类似于将多种积木以不同的方式组合起来构建复杂的结构。以下是各个组成成员的具体作用:

  • 构造函数:用于初始化合约的状态,只在合约创建时执行一次。
  • 函数:包含一组逻辑代码块,负责执行合约的主要功能,可以被外部或内部其他函数调用。
  • 修饰器:用于修改或限制函数的行为,增强函数的复用性和安全性。
  • fallback 函数:当调用合约的函数不存在时执行的特殊函数,用于处理意外的函数调用或额外的数据。
  • receive 函数:专门用来接收以太币(Ether)转账的特殊函数,当合约被发送以太币且未调用任何函数时触发。
  • 结构体:自定义的数据类型,用于组织和存储多个不同类型的数据项。
  • 枚举类型:定义有限个数值的集合,用于提高代码的可读性和增强类型安全。
  • 事件:用于记录合约中发生的特定操作,类似于日志功能,便于外部监听和响应合约的活动。
  • 自定义值类型:允许用户定义新的值类型,可以基于现有的 Solidity 类型,增强代码的清晰度和效率。
  • Error:定义可能在函数执行中抛出的错误,这些错误可以被捕获和处理,增强合约的安全性和稳定性。
  • using 语句:使当前合约能够使用指定库中定义的函数或类型,无需显式调用,简化代码并增强功能。

通过合理利用这些组件,开发者可以构建功能强大且灵活的智能合约,满足各种去中心化应用的需求。

constant

在 Solidity 中,constant 关键字用于定义常量,即那些在编译时就确定且之后无法更改的变量。使用 constant 关键字可以确保一旦值被设定,就无法被意外或恶意修改,从而提高智能合约的安全性。

例如,假设你正在编写一个借贷合约,其中规定了必须维持三倍的抵押率,且这一比率预期在合约的整个生命周期内都不会变化。在这种情况下,你可以将抵押率定义为一个 constant 变量,如下所示:

bash 复制代码
uint constant ratio = 3;

constant 的值必须能在编译期间确定

bash 复制代码
uint a = 3;
uint constant ratio = a; _// 不合法,不能用普通变量给 __constant__ 赋值_
uint constant b; _// 不合法,必须在声明时就赋值_

constant 不能更改

bash 复制代码
uint constant ratio = 3;

constructor() {
    ratio = 0; _// 不合法_
}

function f() public {
    ratio = 0; _// 不合法_
}

immutable

在 Solidity 中,immutable 和 constant 关键字都用于定义变量的值只能设置一次,不过 immutable 相比 constant 提供了更灵活的初始化选项。在前面讨论的"constant"部分我们提到,constant 变量必须在声明时就完成初始化,并且之后不能再进行修改。而 immutable 变量则提供了更宽松的约束,允许变量在声明时初始化,或者在合约的构造函数中进行初始化。

具体来说,使用 immutable 关键字的变量有以下初始化选项:

在变量声明时进行初始化。

在合约的构造函数中进行初始化。

这意味着,如果你选择在声明时不初始化一个 immutable 变量,你还可以在合约的构造函数中为其赋值一次。这种灵活性使得 immutable 变量非常适合用于那些值在部署时才能确定,但之后不再改变的场景。

因此,immutable 和 constant 的主要区别在于初始化的时机和灵活性。constant 适用于那些在编写智能合约代码时就已知且永不改变的值,而 immutable 更适合那些需要在部署合约时根据具体情况确定一次的值。这使得 immutable 在实际应用中提供了更多的便利和效率。

bash 复制代码
// 声明时初始化
uint immutable n = 5;


// 在构建函数初始化
uint immutable n;
constructor () {
    n = 5;
}


//不能初始化两次
uint immutable n = 0;
constructor () {
    n = 5; _// 不合法,已经在声明时初始化过了_
}

immutable 变量不能更改

bash 复制代码
uint immutable n = 0; _// 初始化为0_

function f() public {
    n = 5; _// 不合法,immutable 变量不能更改_
}

函数

在 Solidity 中,函数是智能合约的核心组件之一,包含一组执行特定操作或行为的逻辑代码块。它们定义了合约可以执行的各种功能,是构建合约的基础。例如,一个借贷合约可能会包含多个函数来处理不同的金融操作,如提供资金(supply),借出资金(borrow),和还款(repay)等。每个函数都封装了实现这些操作所需的逻辑,允许合约用户在区块链上执行复杂的交互。

函数声明

我们先来看一个简单函数声明的例子:

两数之和

实现了一个 add 函数,对两个数相加求和

bash 复制代码
function add(uint lhs, uint rhs) public pure returns(uint) {
    return lhs + rhs;
}

在 Solidity 中,函数的声明需要遵循一定的语法规则,以确保函数的正确定义和预期行为。以下是一个函数声明的详细解析:

  • function:函数声明以关键字 function 开始。
  • add:这是函数的名称,用于标识这个特定的函数。
  • (uint lhs, uint rhs):这是函数的输入参数列表,包含两个参数:lhs 和 rhs,它们都是无符号整型(uint)。
  • public:这是函数的可见性修饰符。public 表示该函数可以被合约内部的其他函数以及外部调用。
  • pure:这是函数的状态可变性修饰符。pure 表示该函数不会读取或修改合约的状态。
  • returns(uint):这定义了函数的返回类型,本例中为一个无符号整型(uint)。
  • { return lhs + rhs; }:这是函数的主体部分,包含实际要执行的逻辑。在此例中,函数逻辑是返回两个参数 lhs 和 rhs 的和。

此函数的主要作用是接收两个无符号整数参数 lhs 和 rhs,并返回它们的和。由于这个函数被标记为 public 和 pure,它可以被外部调用,同时保证不会更改或依赖于合约的状态,确保了其执行的纯净性和安全性。

函数可见性

在 Solidity 中,函数的可见性(visibility) 是一个关键的属性,它定义了其他合约和账户能否访问当前合约的函数。Solidity 函数有四种可见性可以选择: public , private , internal , external。、

变量的可见性修饰符

  • public:变量可以被当前合约内部以及外部访问。对于 public 变量,Solidity 自动创建一个访问器函数,允许外部合约也可以读取这些变量。
  • private:变量只能被定义它的合约内部访问。即使是派生合约也无法访问 private 变量。
  • internal:变量可以被当前合约以及所有派生自该合约的"子合约"访问,但不能被外部合约直接访问。

函数的可见性修饰符

  • public:函数可以在当前合约内部和外部被访问。这是函数默认的可见性级别,如果没有指定其他修饰符。
  • external:函数只能从合约外部被调用。这种修饰符通常用于那些不需要在合约内部调用的函数,可优化 gas 消耗。
  • private:函数仅限于在定义它的那个合约内部被调用,不可在任何外部合约或派生合约中访问。
  • internal:函数可以在定义它的合约内部以及所有派生自该合约的子合约中被调用,但不能从外部合约调用。

函数状态可变性

在 Solidity 中,函数状态可变性指的是函数是否有能力修改合约的状态。默认情况下,函数被认为是可以修改合约状态的,即它们可以写入或更改合约中存储的数据。在某些情况下,如果你希望明确地限制函数不改变合约状态,提高合约的安全性和可读性,你可以使用状态可变性修饰符来实现这一点。Solidity 有三种函数状态可变性可以选择:view , pure , payable。

  • view:这种类型的函数仅能查询合约的状态,而不能对状态进行任何形式的修改。简而言之,view 函数是只读的,它们可以安全地读取合约状态但不会造成任何状态改变。
bash 复制代码
uint count;
function GetCount() public view returns(uint) {
    return count;
}
  • pure:pure 函数表示最严格的访问限制,它们不能查询也不能修改合约状态。这类函数只能执行一些基于其输入参数的计算并返回结果,而不依赖于合约中存储的数据。例如,一个计算两数相加的函数可以被标记为 pure。
bash 复制代码
function add(uint lhs, uint rhs) public pure returns(uint) {
    return lhs + rhs;
}
  • payable:payable 修饰符允许函数接收以太币(Ether)转账。在 Solidity 中,函数默认是不接受以太币转账的;如果你的函数需要接收转账,则必须明确指定为 payable。这是处理金融交易时必不可少的修饰符。
bash 复制代码
function deposit() external payable {
    _// deposit to current contract_
}
怎样才算查询合约状态

在 Solidity 中,查询合约状态涉及到多种操作,这些操作可以直接读取或者间接影响合约存储的数据。为了更好地理解和规范这些操作,以下是被明确定义为查询合约状态的五种行为:

  • 读取状态变量:直接访问合约中定义的任何状态变量。
  • 访问余额:使用 address(this).balance 或 .balance 来获取合约或任何地址的当前以太币余额。
  • 访问区块链特性:通过 block,tx,msg 等全局变量的成员访问区块链的特定数据。例如,block.timestamp 获取区块的时间戳,msg.sender 获取消息发送者的地址。
  • 调用非 pure 函数:任何未明确标记为 pure 的函数调用。即便函数本身没有修改状态,但如果它没有被标记为 pure,调用它仍被视为状态查询。
  • 使用内联汇编:特别是那些包含某些操作码的内联汇编,这些操作码可能会读取或依赖于链上数据。
怎样才算修改合约状态

Solidity 有 8 种行为被认为是修改了合约状态:

  • 修改状态变量:直接改变合约中的状态变量。
  • 触发事件:发出事件来记录合约中的活动。
  • 创建其他合约:通过合约代码生成新的合约实例。
  • 使用 selfdestruct:销毁当前合约并将其余额发送到指定地址。
  • 通过函数调用发送以太币:例如使用 transfer 或 send 方法进行以太币转账。
  • 调用未标记为 view 或 pure 的任何函数:调用可能改变状态的函数。
  • 使用低级调用:如 transfer、send、call、delegatecall 等。
  • 使用包含某些操作码的内联汇编:使用可能直接影响状态的汇编代码。

函数修饰器

修饰器在 Solidity 中扮演着重要的角色,它们用于修改或增强函数的行为。通过在函数执行前进行预处理和验证操作,修饰器可以确保函数在合适的条件下执行。例如,修饰器可用于验证函数的输入参数是否符合预定规范,或确认调用者是否拥有执行特定操作的权限。

使用修饰器的主要优势包括提高代码的复用性和增强代码的可读性。将常用的逻辑封装在修饰器中可以避免在多个函数中重复相同的代码,使得合约结构更加清晰,同时也便于维护和更新。

在 Solidity 中,修饰器(modifier)是一种特殊的声明,它用于修改智能合约函数的行为。通过在函数执行前添加预处理和验证逻辑,修饰器可以确保函数在特定条件下运行,例如验证函数的输入参数是否符合预设标准,或确认调用者是否具备特定权限。使用修饰器不仅能增强代码的复用性,还能提升其可读性。

举个例子,考虑以下情况:在一个合约中,几个函数(如 mint、changeOwner、pause)需要确保只有合约的所有者(owner)才能调用它们。通常,我们需要在每个这样的函数前用 require(msg.sender == owner, "Caller is not the owner"); 来检查调用者的身份。这种逻辑在多个函数中重复出现,不仅冗余,而且每次更改时都需要手动更新每个函数。

用 require 来进行权限检查

bash 复制代码
pragma solidity ^0.8.17;

contract ExampleContract {
    address private owner;

    constructor() {
        owner = msg.sender;
    }

    function mint() external {
        require(msg.sender == owner, "Only the owner can call this function.");
        _// Function code goes here_
    }

    function changeOwner() external {
        require(msg.sender == owner, "Only the owner can call this function.");
        _// Function code goes here_
    }

    function pause() external {
        require(msg.sender == owner, "Only the owner can call this function.");
        _// Function code goes here_
    }
}

在这种情况下,我们可以把权限检查的代码抽出来,变成一个修饰器。如果有函数需要权限检查时就可以添加这个修饰器去修饰函数行为。如下面所示:

用修饰器来进行权限检查

bash 复制代码
pragma solidity ^0.8.17;

contract ExampleContract {
    address private owner;

    constructor() {
        owner = msg.sender;
    }

    _// 将权限检查抽取出来成为一个修饰器_
    modifier onlyOwner() {
        require(msg.sender == owner, "Only the owner can call this function.");
        _;
    }

    _// 添加 onlyOwner 修饰器来对调用者进行限制_
    _// 只有 owner 才有权限调用这个函数_
    function mint() external onlyOwner { 
        _// Function code goes here_
    }

    _// 添加 onlyOwner 修饰器来对调用者进行限制_
    _// 只有 owner 才有权限调用这个函数_
    function changeOwner() external onlyOwner {
        _// Function code goes here_
    }

    _// 添加 onlyOwner 修饰器来对调用者进行限制_
    _// 只有 owner 才有权限调用这个函数_
    function pause() external onlyOwner {
        _// Function code goes here_
    }
}

像上面所展示的一样,有了修饰器,你就不需要写重复的代码了。提高了代码复用,降低了出现 bug 的可能性。

修饰器的语法

根据上面的例子,我们不难看出定义修饰器的语法。如下所示:

bash 复制代码
modifier modifierName {
    _// modifier body 1_
    _;
    _// modifier body 2_
}

在 Solidity 中,修饰器的定义和使用都是非常直观的,它们提供了一种强大的方式来封装代码,以便在函数执行前或后进行检查或执行某些操作。定义修饰器时,一个关键元素是使用 _ 占位符,这个占位符指示函数主体应该在何处执行。

以下是修饰器的基本语法和执行顺序的例子:

执行修饰器的前置代码(modifier body 1)。

_ 占位符处执行原函数的主体。

执行修饰器的后置代码(modifier body 2,如果有的话)。

定义修饰器之后,你可以将其应用于任何函数。修饰器紧跟在函数的参数列表后面。这里是一个示例,展示了如何定义和使用修饰器:

  • 添加单个修饰器
bash 复制代码
function foo() public modifier1 {
    _// function body_
}
  • 添加多个修饰器,它们的执行顺序是从左到右的
bash 复制代码
function foo() public modifier1, modifier2, modifier3 {
    _// function body_
}

receive 函数

receive 函数是 Solidity 中的一种特殊函数,主要用于接收以太币(Ether)的转账。此外,还有一个 fallback 函数也可以用来接收以太币转账,我们将在下面详细介绍。

需要注意的是,以太币转账与 ERC20 代币转账之间存在本质区别:

  • 以太币转账:转账的是以太坊的原生代币(native token),即 Ether。
  • ERC20 代币转账:转账的是非原生代币(non-native token),这些代币在合约内部实现类似于一个数据库,记录了每个持有者的代币数量。ERC20 代币转账通过调用普通的合约函数来实现。

receive 函数的定义格式是固定的,其可见性(visibility)必须为 external,状态可变性(state mutability)必须为 payable。同时要注意 receive 函数不需要 function 前缀

bash 复制代码
receive() external payable {
    _// 函数体_
}

fallback 函数

fallback 函数是 Solidity 中的一种特殊函数,用于在调用的函数不存在或未定义时充当兜底。顾名思义,fallback 在中文中有回退、兜底的意思。类似于没有带现金时可以使用银行卡付款。需要注意的是,这里所说的"匹配不到"、"不存在"、"没有定义"都指的是同一个意思。

fallback 函数可以在以下两种情况下兜底:

  • receive 函数不存在(因为没有定义)

  • 普通函数不存在(因为没有定义)

    简而言之:

  • 当需要用到 receive 函数时,如果它没有被定义,就使用 fallback 函数兜底。

  • 当调用的函数在合约中不存在或没有被定义时,也使用 fallback 函数兜底。

示例:receive 和 fallback 函数被调用场景

下面的示例展示了 receive 和 fallback 函数被调用的场景。极力推荐你自己进行尝试一下。可以留意一下注释内容进行操作。

receive 和 fallback 函数被调用场景

bash 复制代码
// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.17;

contract Callee {
    event FunctionCalled(string);

    function foo() external payable {
        emit FunctionCalled("this is foo");
    }

    // 你可以注释掉 receive 函数来模拟它没有被定义的情况,删除之后编译器可能会出现警告,忽略即可
    // payable  是为允许ETH转账修饰器,如果调用时不转 ETH 也合法,但如果没有 payable 修饰符却转 ETH 会报错
    receive() external payable {
        emit FunctionCalled("this is receive");
    }

    // 你可以注释掉 fallback 函数来模拟它没有被定义的情况,删除之后编译器可能会出现警告,忽略即可
    //payable  是为允许ETH转账修饰器,如果调用时不转 ETH 也合法,但如果没有 payable 修饰符却转 ETH 会报错

    fallback() external payable {
        emit FunctionCalled("this is fallback");
    }
}

contract Caller {
    address payable callee;

    // 注意: 记得在部署的时候给 Caller 合约转账一些 Wei,比如 100
    // 因为在调用下面的函数时需要用到一些 Wei
    constructor() payable{
        callee = payable(address(new Callee()));
    }

    // 触发 receive 函数
    function transferReceive() external {
        callee.transfer(1);
    }

    // 触发 receive 函数_
    function sendReceive() external {
        bool success = callee.send(1);
        require(success, "Failed to send Ether");
    }

    // 触发 receive 函数
    function callReceive() external {
        (bool success, ) = callee.call{value: 1}("");
        require(success, "Failed to send Ether");
    }

    // 触发 foo 函数
    function callFoo() external {
        (bool success, ) = callee.call{value: 1}(
            abi.encodeWithSignature("foo()")
        );
        require(success, "Failed to send Ether");
    }

    // 触发 fallback 函数,因为 funcNotExist() 在 Callee 没有定义
    function callFallback() external {
        (bool success, bytes memory data) = callee.call{value: 1}(
            abi.encodeWithSignature("funcNotExist()")
        );
        require(success, "Failed to send Ether");
    }
}

合约没有定义 receive 和 fallback 函数时,不能对其转账

如果一个合约既没有定义 receive 函数,也没有定义 fallback 函数,那么该合约将无法接收以太币转账。在这种情况下,所有试图向该合约进行的转账操作都会被 revert(回退)。

注意 Gas 不足的问题

在定义 receive 函数时,需要特别注意 Gas 不足的问题。前面我们提到,send 和 transfer 方法的 Gas 是固定为 2300 的。因此,这些方法剩余的 Gas 往往不足以执行复杂操作。如果 receive 函数体需要执行较复杂的操作,那么可能会抛出"Out of Gas"异常。

以下操作通常会消耗超过 2300 Gas:

  • 修改状态变量
  • 创建合约
  • 调用其他相对复杂的函数
  • 发送以太币到其他账户

例如,下面的 receive 函数由于消耗的 Gas 超过了 2300,因此它总是会被 revert:

receive 函数消耗过多 GAS

bash 复制代码
// 用send,transfer函数转账到该合约都会被 revert
// 原因是消耗的 Gas 超过了 2300
contract Example {
    uint a;
    receive() external payable {
        a += 1;
    }
}

事件(Event)

在 Solidity 中,事件(Event) 是智能合约向外部(区块链客户端、前端应用、链下服务)传递信息的核心机制,用于记录合约关键操作、状态变更,且事件数据会永久存储在区块链日志中(不可修改),是链上与链下交互的重要桥梁。

  • 一、事件的核心概念
  1. 定义
    事件是合约中用 event 关键字声明的特殊 "日志模板",通过 emit 触发后,会生成一条日志记录存储在区块链的交易收据中:
    日志数据可被链下应用(如前端、数据分析工具)高效查询;
    事件本身不消耗大量 Gas(仅存储索引字段和少量数据),远低于存储变量;
    事件数据无法被合约读取(仅可被外部读取),仅用于 "通知 / 记录"。
  2. 核心特性
    不可篡改:触发后永久写入区块链,与交易记录绑定;
    可索引:支持声明 indexed 关键字,索引字段可被快速过滤查询;
    轻量级:非索引字段存储成本低,适合记录高频操作;
    无返回值:仅用于记录,无法影响合约逻辑。
  • 二、事件的声明与触发
  1. 声明语法
bash 复制代码
// 基础声明
event 事件名(参数类型1 参数名1, 参数类型2 参数名2, ...);

// 带索引字段(最多3个indexed参数)
event 事件名(indexed 参数类型1 参数名1, 参数类型2 参数名2);
  • indexed:标记索引字段,最多支持 3 个,用于链下快速过滤(如按地址、ID 查询);
  • 参数类型:支持基础类型(address、uint256、string、bool 等),string/bytes 作为索引字段时仅存储哈希(需注意)。
  1. 触发语法
    使用 emit 关键字触发事件,参数需与声明匹配:
bash 复制代码
emit 事件名(参数1, 参数2, ...);
  • 三、基础示例
bash 复制代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract EventExample {
    // 声明事件:记录转账操作(2个索引字段:发送者、接收者,非索引:金额)
    event Transfer(
        indexed address from,    // 索引字段:转账发起者
        indexed address to,      // 索引字段:转账接收者
        uint256 amount           // 非索引字段:转账金额(wei)
    );

    // 声明事件:记录NFT铸造
    event NFTMinted(
        indexed address minter,  // 索引字段:铸造者
        indexed uint256 tokenId, // 索引字段:NFT ID
        string tokenURI          // 非索引字段:NFT元数据链接
    );

    // 触发Transfer事件
    function transfer(address to, uint256 amount) public {
        // 业务逻辑(如余额扣减)...
        
        // 触发事件,记录转账行为
        emit Transfer(msg.sender, to, amount);
    }

    // 触发NFTMinted事件
    function mintNFT(uint256 tokenId, string calldata tokenURI) public {
        // 铸造逻辑...
        
        emit NFTMinted(msg.sender, tokenId, tokenURI);
    }
}
相关推荐
Yunpiere1 小时前
Web3:互联网的“去中心化”革命
web3·去中心化·区块链
友莘居士1 小时前
Solidity高阶函数:函数参数的实战应用
区块链·solidity·高阶函数·函数参数
友莘居士4 小时前
Solidity的delete运算符详解
区块链·solidity·以太坊·delete运算符
Web3VentureView6 小时前
特朗普回归到全球金融震荡:链上制度正成为新的稳压器
大数据·金融·web3·去中心化·区块链
区块链小八歌16 小时前
从电商收入到链上资产:Liquid Royalty在 Berachain 重塑 RWA 想象力
大数据·人工智能·区块链
YSGZJJ20 小时前
股指期货的基本概念是什么?
区块链
xinyu_Jina20 小时前
Info Flow:去中心化数据流、跨协议标准化与信息源权重算法
算法·去中心化·区块链
谈笑也风生21 小时前
浅谈:被称为新基建的区块链(一)
区块链