solidity开篇:区块链基础
Solidity 是在兼容 EVM 的区块链上开发智能合约的语言,我们不需要关心所在区块链底层逻辑,只要是兼容 EVM 的公链,我们都可以使用 Solidity 进行智能合约的编码。简单了解以下的区块链概念:
-
事务
-
交易
-
地址
-
区块
-
存储/内存/栈
1、事务
事务意味着你想做的事情,要么一点没做,要么全部完成。具有原子性,不存在修改一半的情况。
比如从 A 地址向 B 地址转账 100 元,那么数据库里 A 减 100 元,B 加 100 元。如果因为某些原因导致 A 已经减了 100 元,但是 B 加 100 元中间出现了异常。因为事务的原子性,发生失败后 A/B 地址都不会发生任何修改。这种场景在合约中经常发生,会经常看到 out of gas 异常,这是因为 gas 被耗尽。此时合约中做的所有修改都会被回滚。
gas:合约的手续费;是作为用户为当前交易支付的手续费,每一笔交易都会收取 gas 费,目的是限制交易需要做的工作量,需要做的事情越多,所花费的 gas 也就越多;gas 会按照特定规则进行逐渐消耗,如果执行完成后还有剩余,gas 会在当前交易内原路返回到交易发起者的地址中。
2.交易
交易可以看作一个地址发送到另外一个地址的消息,可能包含一个二进制数据和以太币。
如果目标地址含有代码,则此代码会被执行,并以 payload 作为入参。
如果目标地址是零地址,此交易将创建一个新合约。
这时候用来创建合约的 payload 会被转为 EVM 字节码并执行,执行的输出作为合约代码永久存在区块链上。
所以如果创建一个合约,并不需要向链上发送实际的合约代码,只需发送能够产生合约代码的代码就可以。
区块链中的交易遵守事务的特性。交易总是由发送人(创建交易的地址)进行签名。区块链底层会确保只有持有该地址密钥才能发起交易。正因为这个特性,所以才能为区块链上特定状态的修改增加保护机制。
比如在合约中指定某一个方法只有"管理员"账号可以用,我们只需要验证调用者是否为管理员地址就可以了,至于地址权限的保护事情并不需要关心,只要是该账号发起的交易,就认为是管理员在操作。安全方面我们需要考虑的是,如果某一个地址被盗了怎么样,通常这些是业务逻辑决定,比如多签钱包的业务。
3.地址
地址很多时候也被称为账户,EVM 中有两类地址,一类是外部地址,一类是合约地址。
外部地址:由公钥-私钥对控制
常用的助记词,keystore 文件等只是方便用户储存,底层还是会转成私钥。
一般是钱包应用创建的地址。公钥就是
0xABC
的这种以太坊收款地址,私钥可能是助记词生成,可能是 keystore 文件生成,也可能是用户直接保存的。合约地址:由地址一起存储的代码控制。
无论外部地址,还是合约地址,对于 EVM 来说,都是一样的。每个地址都有一个键值对形式的持久化存储。其中 key 和 value 都是 256 位,我们称为存储。此外每个地址都会有一个以太币的余额,合约地址也是如此;余额会因为发送包含以太币的交易而改变。
4.区块
你可能听过区块链的双花攻击,女巫攻击等作恶方式。如果你没有听过也没有关系,因为它们对于智能合约开发来说并不重要,我们编写的 Solidity 代码能运行在以太坊网络,也可以运行在 BSC, Matic,Eos EVM 网络等,就像前文说的那样,无论他们采用什么底层逻辑,只要它们支持 EVM 就足够了,底层逻辑不用关心。
我们需要关心的是,区块可能被回滚,交易可能被作废,所以会出现你发起的交易被回滚甚至从区块链中抹除掉的可能。区块链不能保证当前的交易一定包含在下一个区块中。如果你开发的合约有顺序关系,要注意这个特性。合约内的逻辑,不能将某一个块作为依赖。
5.存储/内存/栈
存储:每一个地址都有一个持久化的内存,存储是将 256 位字映射到 256 位字的键值存储区。所以数据类型的最大值是
uint256
/int256
/bytes32
,合约只能读写存储区内属于自己的部分。内存:合约会试图为每一次消息调用获取一块被重新擦拭干净的内存实例。所以储存在内存中的数据,在函数执行完以后就会被销毁。内存是线性的,可按字节级寻址,但读的长度被限制为 256 位,而写的长度可以是 8 位或 256 位。
栈:合约的所有计算都在一个被称为栈(stack)的区域执行,栈最大有 1024 个元素,每一个元素长度是 256 bit;所以调用深度被限制为 1024 ,对复杂的操作,推荐使用循环而不是递归。
2️⃣Hello World
Solidity 合约类似于面向对象语言中的类。合约中有用于数据持久化的状态变量,和可以修改状态变量的函数。 调用另一个合约实例中函数时,会切换执行时的上下文,此时前一个合约的状态变量就不能访问了。后面会逐步展开介绍,国际惯例,使用当前语言的 Hello World 作为第一个例子。
1.例子代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract Hello {
// 24509 gas
string public message = "Hello World!"; // 状态变量
// 24473
function fn1() public view returns (string memory) {
return message;
}
// 21801. 内存中直接返回
function fn2() public pure returns(string memory){
return "Hello World!";
}
// 21880
function fn3() public pure returns(string memory){
return fn2(); // 使用方法;函数调用函数,没有this。直接调用
}
}
2.Hello World 例子分析
上面的代码获取 message 可以得到 "Hello World!"
,调用 fn1()
函数,也可以得到 "Hello World!"
; 这是因为 fn1
里面的逻辑是返回 message。通过这个例子可以发现,合约内调用变量并不需要使用 this
之类的关键字,直接使用即可,调用函数也是如此,直接 fnName([x])
就可以。
通过 Remix 调用详情我们可以发现,他们消耗的 gas 不相同。通常直接获取 message
更省钱,因为message
储存在状态变量中,而函数helloWorld
是读取了状态变量然后再返回出去。但是在 Remix 中有时候得到的结果却并不相同,不用太相信 Remix 内的 gas。在 Remix 中,代码顺序,变量名/函数名长短的修改都可以大大影响 gas 消耗,不要太相信 Remix 的 ga 消耗。
在编写 solidity 代码时,保证安全的前提下,让合约消耗更少的 gas 是一个重要的优化方向。后面会有专门的进行 gas 优化的探讨,这里不再多展开。
3️⃣ 合约代码中的三种注释
我们看到第一行的代码是 // SPDX-License-Identifier: MIT
这里面的 //
符号,是注释符。用来标记和记录代码开发相关的事情,注释的内容是不会被程序运行,Solidity 支持单行注释和块注释,注释是为了更好的解释代码。请不要相信好的代码不需要注释这种鬼言论。代码中加入注释可以更好的团队协作,让自己更好的进行代码开发,以及让阅读者更快捷的理解代码逻辑。在实际工作中经常会出现自己写的代码一年半载之后再看,复杂些的逻辑可能需要浪费很多时间在代码理解上,如果再没有设计图和代码注释,简直想骂人。
Solidity 支持 3 种注释方式;
-
单行注释
-
块注释
-
NatSpec 描述注释
1.单行注释
格式: // 注释内容
// SPDX-License-Identifier: MIT string message = "Hello World!"; // 这是单行注释
如上,//
后面的内容都会被编译器忽略,为了可读性,一般会在//
后面加一个空格。
2.块注释
格式如下,在 /*
与 */
之间的内容,都被编译器忽略
/* 这是块注释 */
为了可读性,一般块注释的行首都加 *
和空格,如下
/** * 这是块注释 * 这是块注释 */
3.NatSpec 描述注释
单行使用 ///
开始,多行使用 /**
开头以 */
结尾。NatSpec 描述注释的作用非常重要,它是为函数、返回变量等提供丰富的文档。**在编写合约的时候,强烈推荐使用 NatSpec
为所有的开放接口(只要是在 ABI
里呈现的内容)进行完整的注释。**
⓵ 简单演示
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
/// @title 一个简单的数据存储演示
/// @author kyp
/// @notice 您智能将此合约用于最基本的演示
/// @dev 提供了存储方法/获取方法
/// @custom:xx 自定义的描述/这个是实验的测试合约
contract TinyStorage {
// data
uint256 storedData;
/// @notice 储存 x
/// @param _x: storedData 将要修改的值
/// @dev 将数字存储在状态变量 storedData 中
function set(uint256 _x) public{
storedData = _x;
}
/// @notice 返回存储的值
/// @return 储存值
/// @dev 检索状态变量 storedData 的值
function get() public view returns(uint256){
return storedData;
}
/**
* @notice 第二种写法
* @param _x: XXXXX
* @dev XXXXX
* @return XXXXX
* @inheritdoc :
*/
}
上面所有标签都是可选的。下表解释了每个 NatSpec 标记的用途以及可以使用在哪些位置。我们可以选择合适的标记进行记录
标签 | 说明 | 语境 |
---|---|---|
@title | 描述 contract/interface 的标题 | contract, interface, library |
@author | 作者姓名 | contract, interface, library |
@notice | 向最终用户解释这是做什么的 | contract, interface, library, function, 公共状态变量 event |
@dev | 向开发人员解释任何额外的细节 | contract, interface, library, function, 状态变量, event |
@param | 记录参数(后面必须跟参数名称) | function, event, 自定义错误 |
@return | 函数的返回变量 | function, 公共状态变量 |
@inheritdoc | 从基本函数中复制所有缺失的标签(必须后跟合约名称) | function, 公共状态变量 |
@custom:... | 自定义标签,语义由应用程序定义 | 所有位置均可以 |
⓶ 文档输出
使用 NatSpec
描述注释的另一个好处是,当被编译器解析时,上面示例中的代码将生成两个不同的 JSON 文件。
-
User Documentation:供最终用户在执行功能时作为通知使用的
-
Developer Documentation:供开发人员使用的。
如果将上述合约另存为,a.sol
则您可以使用以下命令生成文档:
solc --userdoc --devdoc a.sol
⓷ 继承说明
TODO: 在后面合约继承的时候再演示使用。
如果函数是继承别的合约,没有 NatSpec 的函数将自动继承其基本函数的文档。但是下面三种情况是例外的:
-
当参数名称不同时。
-
这时候是函数的重载,函数签名已经发生了改变。
-
-
当有多个基本功能时。
-
这时候因为发生了冲突,supper 中有多个父级
-
-
当有一个明确的
@inheritdoc
标签指定应该使用哪个合约来继承时。
更多 NatSpec 请参考: GitHub - aragon/radspec: 🤘 Radspec is a safe interpreter for Ethereum's NatSpec