3. Solidity语言核心特性:数据类型与存储布局、函数可见性与修饰符、事件与日志、继承与库合约

好,咱们直接进入正题。Solidity 这门语言,说难不难,说简单也不简单。很多新手写合约,漏洞百出,其实根源就在于没吃透这几个核心特性。今天我就带你把这些硬骨头啃下来。

3.1 数据类型与存储布局

先聊聊数据类型。Solidity 是静态类型语言,每个变量都得明确类型。我个人习惯把类型分成两类:值类型引用类型

值类型包括:

  • bool:布尔值,true/false
  • int/uint:有符号/无符号整数,从8位到256位
  • address:以太坊地址,20字节
  • bytes1 ~ bytes32:定长字节数组
  • enum:枚举类型

引用类型包括:

  • string:动态字符串
  • bytes:动态字节数组
  • array:数组
  • struct:结构体
  • mapping:映射表

这里有个关键点——存储位置。Solidity 有三种存储位置:storagememorycalldata。我见过太多人在这上面栽跟头了。

核心区别:

  • storage:永久存储在区块链上,修改会写进状态
  • memory:临时存储,函数执行完就释放
  • calldata:只读的临时存储,用于外部函数参数

举个例子你就明白了:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract StorageDemo {
    uint[] public arr;  // 默认是 storage

    function f(uint[] memory _input) public {
        uint[] storage localArr = arr;  // 引用 storage,修改会影响全局
        localArr.push(1);

        uint[] memory memArr = _input;  // memory 副本,修改不影响原数据
        memArr[0] = 999;
    }
}

我曾经审计过一个项目,开发者在函数内部把 storage 变量赋值给 memory 变量,结果改了副本以为改了原数据,导致状态一直不对。嗯,这种 bug 排查起来特别头疼。

3.2 函数可见性与修饰符

函数的可见性,说白了就是「谁能调用这个函数」。Solidity 提供了四种:

可见性 外部调用 内部调用 子合约调用
public
external ❌(需用 this)
internal
private

你想想看,externalpublic 有什么区别?external 函数的参数可以直接用 calldata,省 gas。我一般建议:如果函数只给外部调用,就用 external

再来说说修饰符。修饰符是 Solidity 里很强大的特性,可以用来做权限控制、重入锁等。

contract ModifierDemo {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;  // 继续执行原函数
    }

    function sensitiveAction() public onlyOwner {
        // 只有 owner 能调用
    }
}

这里有个小技巧:_ 的位置决定了原函数代码插入的位置。你可以把 _ 放在修饰符的开头、中间或结尾,实现不同的逻辑。

我的经验:写修饰符时,记得把 _ 放在 require 之后。否则 require 失败时,状态可能已经被修改了。我曾经审计过一个合约,修饰符里先执行了转账再检查权限,结果...你懂的。

3.3 事件与日志

事件是 Solidity 里和前端交互的主要方式。说白了,合约里发生什么事,通过事件告诉外部世界。

event Transfer(address indexed from, address indexed to, uint256 value);

function transfer(address to, uint256 amount) public {
    // 转账逻辑...
    emit Transfer(msg.sender, to, amount);
}

这里要注意 indexed 关键字。被 indexed 的参数会被索引,方便前端快速检索。但最多只能有 3 个 indexed 参数。

事件的数据存储在交易收据的 logs 字段里。你想想看,这比直接存到 storage 里便宜多了。所以很多项目会用事件来记录历史数据,而不是存到状态变量里。

避坑指南:我曾经审计过一个 DeFi 项目,他们把用户的所有操作都记录在 storage 里。结果 gas 费高得离谱,用户根本用不起。后来改成用事件记录,成本降了 90%。记住:能用事件就别用 storage

3.4 继承与库合约

Solidity 支持多重继承,但它的继承机制和传统 OOP 语言不太一样。核心是 C3 线性化 算法,用来解决菱形继承问题。

contract A {
    function foo() public virtual pure returns (string memory) {
        return "A";
    }
}

contract B is A {
    function foo() public virtual override pure returns (string memory) {
        return "B";
    }
}

contract C is A {
    function foo() public virtual override pure returns (string memory) {
        return "C";
    }
}

contract D is B, C {
    function foo() public override(B, C) pure returns (string memory) {
        return super.foo();  // 调用 C 的 foo()
    }
}

这里 super.foo() 调用的是哪个?答案是 C。因为 Solidity 的继承顺序是从右到左,从最派生到最基类。D 继承 B 和 C,C 在右边,所以优先调用 C。

再说说库合约。库合约是用 library 关键字定义的,它有几个特点:

  • 不能有状态变量
  • 不能继承或被继承
  • 不能接收以太币
  • 函数默认是 internal

库合约最常见的用法是 using for 语法,给某个类型添加方法:

library Math {
    function add(uint a, uint b) internal pure returns (uint) {
        return a + b;
    }
}

contract Test {
    using Math for uint;

    function testAdd() public pure returns (uint) {
        uint x = 5;
        return x.add(3);  // 相当于 Math.add(5, 3)
    }
}

我个人特别喜欢用库合约来封装通用的数学运算、地址校验等逻辑。这样主合约代码更干净,也更容易测试。

核心总结:

  • 数据类型要分清值类型和引用类型,存储位置要选对
  • 函数可见性按需选择,external 比 public 省 gas
  • 事件是低成本的数据记录方式,能不用 storage 就别用
  • 继承用 C3 线性化,库合约用 using for 更优雅

好了,这一章的内容就到这里。这些核心特性是写安全合约的基石,建议你多写多练,把每个细节都吃透。


专注资料整理