solidity基础语法
全局变量和函数
var 类型变量
上一章未讨论的一种 Solidity 类型是 var数据类型。
var 是一种特殊类型,只能在函数内部声明。
Contract中不能存在 var 类型的状态变量。
使用 var 类型声明的变量被称为隐式类型变量,因为 var 本身并不显式地表示任何类型。
它告知编译器,其类型取决于首次赋值的值。
一旦类型确定,就无法更改。
编译器会决定 var 变量的最终数据类型,而不是由开发者指定。因此,编译器根据 block.difficulty(uint)当前代码块确定的类型,很可能与代码执行所期望的类型不完全一致。var 不能与显式内存地址一起使用。显式内存地址需要显式变量类型。
变量提升
变量提升
变量提升是指变量无需在使用前声明和初始化。变量声明可以发生在函数内的任何位置,甚至在使用之后。这被称为变量提升。
Solidity 编译器会提取函数内所有已声明的变量,并将它们放置在函数的开头。
我们都知道,在 Solidity 中声明变量也会使用其各自的默认值进行初始化。这确保了变量在整个函数中都可用。
在下面的示例中,firstVar、secondVar 和 result 在函数末尾附近声明,但在函数开头使用。
然而,当编译器为合约生成字节码时,它会将所有变量声明为函数的第一组指令,如下图所示:
变量作用域
作用域指的是变量在函数和契约中的可用性。
Solidity 提供了以下两个变量声明位置:
- 契约级全局变量——也称为状态变量
- 函数级局部变量
函数级局部变量很容易理解。它们仅在函数内部可用,外部不可用。
合约级全局变量是所有函数(包括构造函数、回退函数和修饰符)都可以访问的变量。合约级全局变量可以附加可见性修饰符。需要注意的是,状态数据可以在整个网络中被查看,而与可见性修饰符无关。以下状态变量只能通过函数进行修改。
public:这些状态变量可直接从外部调用访问。
编译器会隐式生成一个 getter 函数来读取公共状态变量的值。
internal:这些状态变量不能直接从外部调用访问。
它们只能从当前合约及其子合约中的函数访问。
private: 这些状态变量无法直接从外部调用访问。它们也无法从子合约中的函数访问。
它们只能从当前合约内部的函数访问。
类型转换
现在我们知道,Solidity 是一种静态类型语言,其中变量在编译时被定义特定的数据类型。
在变量的生命周期内,其数据类型不能更改。
这意味着它只能存储该数据类型允许的值。
例如,uint8 可以存储 0 到 255 之间的值。它不能存储负值或大于 255 的值。请查看以下代码以更好地理解这一点:
然而,有时需要进行类型转换,例如将一种类型的变量值复制到另一种类型的变量中,这些转换称为类型转换。Solidity 提供了类型转换规则。
在 Solidity 中,我们可以执行各种类型的转换,我们将在以下章节中介绍这些转换。
隐式转换
隐式转换意味着无需运算符,
或者说转换不需要任何外部帮助。这类转换完全合法,不会造成数据丢失或值不匹配。它们完全类型安全。
Solidity 允许从较小的整数类型隐式转换为较大的整数类型。
例如,将 uint8 转换为 uint16 是隐式进行的。
显式转换
当编译器由于数据丢失或值包含的数据超出目标数据类型范围而无法执行隐式转换时,需要进行显式转换。
Solidity为每种值类型都提供了一个用于显式转换的函数。
显式转换的示例是将 uint16 转换为 uint8。在这种情况下,可能会出现数据丢失。
以下代码清单展示了隐式和显式转换的示例:
ConversionExplicitUINT8toUINT256:此函数执行从 uint8 到 uint256 的显式转换。需要注意的是,这种转换也可以隐式进行。
ConvertionExplicitUINT256toUINT8:此函数执行从 uint256 到 uint8 的显式转换。如果转换是隐式发生的,则此转换将引发编译时错误。
ConvertionExplicitUINT256toUINT81:此函数展示了显式转换的一个有趣方面。显式转换容易出错,通常应尽量避免。在此函数中,尝试将一个较大的值存储到一个较小数
conversions:此函数展示了隐式和显式转换的示例。
有些转换失败,有些转换合法。在以下屏幕截图中,请阅读代码下方的注释以了解它们。
区块和交易全局变量
Solidity 提供了一些全局变量的访问权限,这些变量并非在合约中声明,但可以从合约内部的代码访问。合约无法直接访问账本。账本仅由矿工维护;然而,Solidity 会向合约提供一些关于当前交易和区块的信息,以便合约可以利用这些信息。Solidity 提供与区块和交易相关的变量。
以下代码示例展示了如何使用global transaction, block,和message变量。
transaction和message global变量
以下是全局变量及其数据类型列表,
以及便于查阅的说明:
跳过
tx.origin 与 msg.sender 的区别
细心的读者可能注意到,在前面的代码示例中,tx.origin 和 msg.sender 的结果和输出看起来相同。
实际上,tx.origin 全局变量指向最初发起该交易的外部账户(EOA);而 msg.sender 指向当前调用该函数的直接账户(该账户可以是外部账户,也可以是合约账户)。
tx.origin 始终指向那个最初发起整笔交易的 EOA;
但 msg.sender 可能是外部账户,也可能是某个合约。
如果一笔交易中发生了多个合约之间的函数调用,无论调用链(call stack)有多深,tx.origin 总是引用最初发起交易的账户;
而 msg.sender 则始终指向上一个调用当前合约的账户或合约。
安全实践上,强烈建议使用 msg.sender,而不要使用 tx.origin。
Cryptography global variables加密学相关的全局变量
Solidity 在合约函数中提供了用于对数据进行哈希的加密函数。主要有两类哈希函数:SHA2 和 SHA3。
sha3 函数使用 SHA3 算法将输入转换为哈希值;
sha256 函数使用 SHA2 算法将输入转换为哈希值。
Solidity 还提供了另一个函数 keccak256,它实际上是 SHA3 算法的别名。
在实际开发中,推荐使用 keccak256 或 sha3 来满足哈希计算的需求。
下面的代码片段截图展示了相关使用方式:
地址全局变量
每个地址(无论是外部拥有的还是基于合约的)都有五个全局函数和一个全局变量。
这些函数和变量将在后续关于 Solidify函数的章节中进行深入探讨。
与地址相关的全局变量称为balance,它提供该地址当前可用的以太币余额(以 wei 为单位)。
Contract global variables合约的全局变量
每个合约都具有以下三个全局函数:
- this:当前合约的类型,可显式转换为 address 类型。
- selfdestruct:接收一个地址参数,用于销毁当前合约,并将该合约持有的资金发送到指定地址。
- suicide:同样接收一个地址参数,是 selfdestruct 的别名。
总结
本章在许多方面延续了前几章的内容。章节前半部分深入讨论了变量,包括变量提升、类型转换、var 数据类型的细节以及 Solidity 变量的作用域,并辅以代码示例进行了说明。章节后半部分则聚焦于全局可用的变量与函数,对与交易和消息相关的变量(如 block.coinbase、msg.data 等)进行了讲解。本章还解释了 msg.sender 与 tx.origin 的差异及其使用方式。此外,本章也讨论了加密相关、地址相关以及合约层面的函数。不过,我们将在本书后续的章节中再进一步深入这些函数。
下一章将重点介绍 Solidity 的表达式与控制结构,包括循环与条件判断等编程细节。这是非常重要的一章,因为任何程序都需要通过循环来执行重复性任务,而 Solidity 的控制结构正是实现这些逻辑的关键。循环依赖条件,而条件通过表达式来书写;这些表达式经过求值后会返回 true 或 false。
请继续阅读,我们将在下一章深入探讨控制结构与表达式。
表达式与控制结构
在代码中做出决策是编程语言的重要方面,Solidity 也需要根据不同情况执行不同的指令。Solidity 提供了 if...else 和 switch 语句来实现这一目的。同时,循环遍历多个元素也非常重要,Solidity 为此提供了多种构造,例如 for 循环和 while 语句。在本章中,我们将详细讨论这些编程构造,它们可以帮助你做出决策并循环处理一组数值。
Solidity 表达式
表达式是指一个语句(由多个操作数组成,可选择性地包含零个或多个运算符),其最终产生一个单一的值、对象或函数。操作数可以是字面量、变量、函数调用,或者另一个表达式本身。
if 决策控制
Solidity 通过 if...else 语句提供条件代码执行。if...else 的一般结构如下:
while 循环
有时我们需要根据某个条件重复执行一段代码。Solidity 提供了 while 循环正是为了实现这一目的。while 循环的一般形式如下:
for 循环
for 循环是最著名、最常用的循环之一,我们可以在 Solidity 中使用它。for 循环的一般结构如下:
for 是 Solidity 中的一个关键字,用于告诉编译器它包含有关循环执行一组指令的信息。它与 while 循环非常相似,但更加简洁且可读性更强,因为所有信息都可以在一行中查看。
下面的代码示例展示了同样的解决方案:遍历一个映射。不过,这里使用的是 for 循环而不是 while 循环。变量 i 会被初始化,在每次迭代中递增 1,并检查是否小于 counter 的值。一旦条件不成立,即 i 的值等于或大于 counter,循环将停止。
do…while 循环
do...while 循环与 while 循环非常相似。do...while 循环的一般形式如下:
- 声明并初始化一个计数器
do {
执行这里的指令
计数器的值加 1} while (使用表达式或条件检查计数器的值)
while 循环与 do...while 循环之间有一个微妙的区别。如果注意到,do...while 中的条件被放在循环指令的末尾。对于 while 循环,如果条件为假,循环体中的指令将完全不执行;而 do...while 循环中的指令至少会执行一次,然后才对条件进行评估。因此,如果你希望指令至少执行一次,应优先使用 do...while 循环而不是 while 循环。
请看下面代码片段的截图:
break
循环有助于从起始位置迭代直到遍历完一个向量(vector)数据类型。然而,有时你可能希望在迭代过程中中途停止,直接跳出循环,而不再执行条件测试。break 语句可以实现这一功能。它通过将控制权传递到循环之后的第一条指令,从而终止循环。
在下面的截图示例中,由于使用了 break 语句,当 i 的值为 1 时,for 循环被终止,控制权跳出 for 循环。正如截图所示,它会直接“打断”循环:
storage / memory / calldata —— 理解三者差异
以下内容将以审计工程师和 Solidity 开发者的视角,用最精确、工程化、并贴近 EVM 的方式讲清楚 storage / memory / calldata 的根本差异、使用场景、成本模型以及常见审计风险。
1. 三者的本质差异(核心一句话)
- storage:永久存储在链上的数据,写入最贵,持久化,跨函数有效。对应 EVM 的
SSTORE / SLOAD。 - memory:函数执行期间的临时内存,不上链,便宜,生命周期是一次调用。对应 EVM 的
MSTORE / MLOAD。 - calldata:外部传入的只读参数区,不可修改,最便宜。EVM 的只读输入区,带有 gas 退款优势。
2. 生命周期与可变性(开发时最容易混淆)
| 属性 | storage | memory | calldata |
|---|---|---|---|
| 生命周期 | 合约永久 | 函数执行期 | 外部调用输入期 |
| 是否可修改 | 可修改 | 可修改 | 不可修改 |
| 是否持久化 | 是 | 否 | 否 |
| 访问方式 | 读写都贵 | 读写便宜 | 只读且最便宜 |
| 常见用途 | 状态变量 | 局部变量、临时数组 | external 函数的入参 |
3. Gas 成本(审计中非常关键)
按平均消耗排序:
calldata < memory << storage
storage
SSTORE写入极贵:- 从 0 → 非零:20,000 gas
- 非零 → 非零:5,000 gas
- 非零 → 0:可以退 gas(最多 15,000)
SLOAD也贵:约 100 gas
memory
MLOAD/MSTORE:3 gas + 扩容成本- 与存储相比非常便宜,但大数组有扩容成本
calldata
最便宜,不扩容
参数本身的字节数收费:
- 非零字节:16 gas
- 0 字节:4 gas
4. 典型代码对比(你马上能看懂差异)
4.1 storage(持久变量)
1 | |
4.2 memory(临时变量)
1 | |
4.3 calldata(external 调用的入参)
1 | |
5. 审计视角:最常见的漏洞与错误用法
(1) storage 指针误用(最常见且致命)
1 | |
审计常见问题:开发者以为自己拷贝了一份,但实际拿到了引用。
(2) 不必要使用 storage,导致 gas 爆炸
1 | |
绝大多数情况应该用 memory 或 calldata。
(3) external 函数未用 calldata,导致 gas 多 20%–40%
1 | |
应改为:
1 | |
(4) bytes/string 未区分 memory 与 calldata
calldata 在处理大型字符串时非常节省 gas。
6. 最佳实践总结(工程化、可立即执行)
- 所有 external 函数的入参数组、字符串一律用 calldata。
- 内部临时变量使用 memory。
- 只有需要持久化的数据才用 storage。
- struct / array 在复制时时刻注意是否是 storage 指针。
- 循环中尽量避免频繁 SLOAD/SSTORE,先读入 memory 再处理。
7. 一句话记住它们
- storage = 链上永久仓库,贵但持久
- memory = 函数沙盒内存,便宜但临时
- calldata = 外部传入的快读区,最便宜
constructor / modifier
在 Solidity 中,constructor 和 modifier 是两个非常重要的概念,功能和用途完全不同。我给你详细解释一下:
1️⃣ constructor(构造函数)
作用:
- 构造函数是在 合约部署时自动执行的一段代码,只执行一次,用于初始化合约的状态。
- 通常用于设置初始值(比如合约拥有者、初始余额等)。
特点:
- 每个合约只能有一个构造函数。
- 构造函数没有返回值。
- Solidity 0.4.x 以前构造函数与合约同名,0.4.22 以后推荐使用
constructor关键字。
示例:
1 | |
部署这个合约时,constructor 会自动执行一次,将合约拥有者设为部署者地址。
2️⃣ modifier(修饰器/限定器)
作用:
modifier用于 修改函数行为,通常用于权限控制、条件检查或预处理。- 可以在执行函数体之前或之后插入逻辑。
特点:
- 可以重用,提高代码可读性和安全性。
- 通常用
_;表示原函数执行的位置。
示例:
1 | |
在这个例子中:
onlyOwner是一个 modifier,用于限制只有合约拥有者可以调用sensitiveAction。- 当调用
sensitiveAction时,先执行onlyOwner中的检查,再执行函数体。
💡 总结:
| 关键字 | 功能 | 什么时候执行 |
|---|---|---|
constructor |
初始化合约状态 | 合约部署时,只执行一次 |
modifier |
修改函数行为(权限、条件检查等) | 函数调用前或调用后可执行 |
如果你愿意,我可以画一张 “constructor 与 modifier 执行顺序示意图”,帮助你更直观理解它们在合约生命周期中的作用。
payable 函数
在 Solidity 中,payable 函数属于 函数与以太币转账机制 相关的知识点,具体可以归类为以下几个板块:
1️⃣ 函数类型与修饰符
payable是 函数修饰符(类似modifier的概念,但专门用于函数),用来标记函数可以接收以太币(ETH)。- 没有
payable的函数 不能接收 ETH,否则交易会失败。
示例:
1 | |
msg.value:表示本次交易发送的以太币数量(单位是 wei)。
2️⃣ 合约与以太币交互
payable也可以用于 地址类型,如address payable,允许向该地址发送 ETH:
1 | |
- 通过
transfer、send、call方法将 ETH 发送到address payable。
3️⃣ 支付逻辑与安全性
函数必须声明为
payable才能接收 ETH,否则交易回退(revert)。payable函数常用于:- 存款/众筹合约
- ICO/代币销售合约
- 奖励或付款系统
💡 总结归类:
| 知识块 | 内容 |
|---|---|
| 函数修饰符 | payable 修饰函数,使其可以接收 ETH |
| 地址类型 | address payable,允许向地址发送 ETH |
| 交易与 msg 对象 | msg.value 表示发送的 ETH 数量,配合 payable 使用 |
| 支付逻辑与安全性 | 支付必须用 payable,否则交易回退;涉及以太币接收/发送安全控制 |
如果你愿意,我可以帮你画一张 “payable 函数接收 ETH 的流程图”,把 msg.value、address payable、函数调用顺序直观展示出来,让你一眼理解机制。
event + emit
在 Solidity 中,event 和 emit 属于 事件与日志(Events & Logging)机制 相关的知识点,可以归类如下:
1️⃣ 事件(Event)
event用于在 区块链上记录日志,便于 DApp 或前端监听合约状态变化。- 事件本质上不会存储在合约的存储变量中,而是存储在 交易日志(logs) 中,节省 gas。
- 通常用在状态变化后通知外部应用或监听特定操作。
声明示例:
1 | |
indexed关键字可以让前端高效筛选特定事件。
2️⃣ 触发事件(emit)
emit用于 触发事件 并将数据写入交易日志。- 每次合约调用
emit都会生成一个日志条目,可被区块浏览器或前端 DApp 监听。
触发示例:
1 | |
3️⃣ 事件的应用场景
- 记录用户行为:存款、取款、转账等
- 状态变化通知前端:例如 DApp 更新界面
- 事件监听:通过 Web3.js / Ethers.js 监听事件触发 UI 或其他逻辑
4️⃣ 归类总结
| 知识块 | 内容 |
|---|---|
| 事件声明 | event 关键字声明事件,可带参数(可 indexed 便于筛选) |
| 事件触发 | emit 关键字触发事件,将信息写入交易日志 |
| 日志与外部交互 | 事件不会占用合约存储,而是生成日志,前端可通过 Web3.js 或 Ethers.js 监听 |
如果你愿意,我可以帮你画一张 “Solidity 事件触发流程图”,把函数调用、状态变化、事件触发和前端监听完整展示出来,让 event + emit 的机制一眼就看懂。