学习 Solidity——智能合约开发手册(二)
作者:Zubin Pratap (英语)译者:Frank Kong本文是《学习 Solidity——智能合约开发手册》的第二篇文章。点击此处,了解本系列的第一篇文章。
目录
这本手册是为谁而写的
必要的前置知识
什么是 Solidity
什么是智能合约
怎样在 Solidity 中声明变量和函数
智能合约中的变量作用域
如何使用可见性标识符(visibility specifier)
什么是构造函数
接口和抽象合约
智能合约案例 #2
什么是合约状态
状态可变性关键字(修饰符:modifier)
数据存储类型 – storage/memory/stack
数据类型原理
Solidity 数据类型
Solidity 中数组如何声明和初始化数组
函数修饰符(function modifier)是什么
Solidity 中的异常处理 – require/assert/revert
Solidity 中的继承
继承与构造函数参数
Solidity 中的类型转换
Solidity 中如何使用浮点数
哈希、ABI 编码(encoding)和解码(decoding)
如何调用合约并且使用 fallback 函数
如何发送和接收 Ether
Solidity 库(library)
Solidity 中的事件(events)和日志(logs)
Solidity 中的时间逻辑
总结和更多资源
数据类型原理
类型是编程中一个非常重要的概念,因为它是我们为数据提供结构的方式。从该结构中,我们可以以安全、一致和可预测的方式对数据运行操作。当一种语言具有严格类型时,这意味着该语言严格定义了每条数据的类型,并且不能为具有类型的变量赋予另一种类型。换句话说,在严格类型语言中:int a =1 // 这里的 1 是整数类型 string b= “1” // 这里的 1 是字符串类型b=a // 非法! b 是一个字符串,它不能承载整数类型,同理 a 也一样!但是在没有类型的 JavaScript 中,b=a 也成立——这使得 JavaScript 成为“动态类型”。同样,在静态类型的语言中,你不能将整数传递给需要字符串的函数。但是在 JavaScript 中,我们可以将任何东西传递给函数,程序仍然可以编译,但在执行程序时可能会抛出错误。例如这个函数:
function add(a,b){ return a + b } add(1, 2) // 输出是 3,整数类型 add(1, “2”) // “2” 是一个字符串,而不是整数,所以输出变成了字符串“12” (!?)可以想象,这会产生一些很难发现的错误。尽管它会产生意想不到的结果,但是代码编译甚至可以执行都不会失败。但是强类型语言永远不会让你传递字符串“2”,因为函数会坚持它接受的类型。让我们看看这个函数是如何用像 Go 这样的强类型语言编写的。Solidity 数据类型
内置于语言中并且可以“开箱即用”的类型通常被称为“原语(primitive)”。它们是语言固有的。你可以组合 primitive 数据类型以形成更复杂的数据结构,这些数据结构成为“自定义(custom)”数据类型。例如,在 JavaScript 中,primitive不是 JS 对象并且也没有方法或属性的数据。JavaScript 中有 7 种基本数据类型:string、number、bigint、boolean、undefined、symbol 和 null。Solidity 也有自己的 primitive 数据类型。有趣的是,Solidity 没有“undefined”或“null”。相反,当你声明一个变量及其类型,但不为其分配值时,Solidity 将为该类型分配一个默认值。该默认值究竟是什么,取决于数据类型。Solidity 的许多 primitive 数据类型都是相同“基本”类型的变体。例如,int 类型本身具有子类型,而子类型就基于 integer 可以存储的二进制位。如果这让你有点困惑,请不要担心 – 如果你不熟悉位和字节,这并不容易,我将很快介绍整数。在我们探索 Solidity 类型之前,你必须了解另一个非常重要的概念 – 它是编程语言中许多错误和“意外陷阱”的来源。这就是值类型(value type)和引用类型(reference type)之间的区别,以及程序中数据“按值传递(pass by value)”与“按引用传递(pass by reference)”之间的区别。我将在下面进行快速总结,但你还可以在继续之前观看这段简短的视频。按引用传递 vs 按值传递
在操作系统级别,当程序运行时,程序在执行期间使用的所有数据都存储在计算机 RAM(内存)中的位置。当你声明一个变量时,操作系统会分配一些内存空间来保存该变量的数据,这些存储空间会分配给或最终分配给该变量的值。还有一种数据,就是常说的“指针”。该指针指向可以找到该变量及其值的内存位置(计算机 RAM 中的“地址”)。因此,指针实际上包含了对计算机内存中数据所在位置的引用。因此,当你在程序中传递数据时(例如,当你将值分配给新变量名称时,或者当你将输入(参数)传递给函数或方法时,语言的编译器可以通过两种方式实现这一点。它可以通过指向计算机内存中数据位置的指针,或者它可以复制数据本身,并传递实际值。第一种方法是“通过引用传递”。第二种方法是“按值传递”。Solidity 的数据类型基元分为两类——它们要么是值类型(value type),要么是引用类型(reference type)。换句话说,在 Solidity 中,当你传递数据时,数据的类型将决定你传递的是值的副本还是对值在计算机内存中位置的引用。如何在 Solidity 中声明和初始化数组
Solidity 有两种类型的数组,因此了解声明和初始化它们的不同方式是很有必要的。Solidity 中的两种主要数组类型是固定大小数组和动态大小数组。为了强化你的记忆,请回忆前几节内容。固定大小的数组按值传递(在代码中传递时复制),动态大小的数组按引用传递(指向内存地址的指针在代码中传递)。它们的语法和容量(大小)也不同,这决定了我们何时使用其中一个与另一个。这是固定大小的数组在声明和初始化时的样子。它的固定容量为 6 个元素,一旦声明就不能更改。6 个元素的数组的内存空间已分配且无法更改。string[6] fixedArray; // 最大空间是 6 个元素。fixedArray[0] = ‘a’; // 第 1 个元素设置为 ‘a’fixedArray[4]=‘e’; // 第 5 个元素设置为 ‘e’fixedArray.push(‘f’) // 不能这么做. 固定大小的数组不能使用 push() 函数fixedArray[5]=‘f’; // 第 6 个元素设置为 ‘f’fixedArray[6]=‘g’; // 不能这么做, 超出了固定的大小也可以通过使用以下语法声明一个固定大小的数组,声明中包含变量名,数组的大小及其元素的类型:// datatype arrayName[arraySize];string myStrings[10]; // 大小为 10 的字符串数组myStrings[0] = “chain.link”;与其不同,按如下方式声明和初始化的动态大小数组,它的容量是不确定的,这样你就可以使用 push() 方法添加元素:uint[] dynamicArray;// Push:在数组末尾增加一个值// 数组的长度会加 1dynamicArray.push(1); dynamicArray.push(2); dynamicArray.push(3); // dynamicArray 现在是 [1,2,3]uint[] dynamicArray;// Push:在数组末尾增加一个值// 数组的长度会加 1dynamicArray.push(1); dynamicArray.push(2); dynamicArray.push(3); // dynamicArray 现在是 [1,2,3]dynamicArray.length; // 3// Pop:删掉最后一个元素// 数组的长度会减 1uint lastNum = dynamicArray.pop() dynamicArray.length; // 2// delete 关键字将指定索引的值重制回默认值delete dynamicArray[1]; // 第二个元素不再是 2,而是 0 了你还可以在同一行代码中声明和初始化数组的值。
string[3] fixedArray = [“a”, “b”, “c”]; // 固定大小的字符串数组 fixedArray.push(“abc”); // 不会成功,因为是固定长度的数组 String[] dynamicArray =[“chainlink”, “oracles”]; /// 动态大小的数组 dynamicArray.push(“rocks”); // 会成功.这些数组是在 storage 中存储的。但是,如果你只需要函数内的临时数组(存储在 memory)怎么办?在这种情况下,有两条规则:只允许使用固定大小的数组,并且必须使用 new 关键字。function inMemArray(string memory firstName, string memory lastName) public pure returns (string[] memory){ // 在 memory 中创建一个长度为 2 的固定大小数组 string[] memory arr = new string[](2); arr[0] = firstName; arr[1] = lastName; return arr; }显然,有几种方法可以声明和初始化数组。当你想要对 gas 和计算进行优化时,你需要仔细考虑需要哪种类型的数组、它们的容量是多少,以及它们是否可能在没有上限的情况下增长。这也会影响你的代码设计并受其影响——你是需要数组存储在 storage 还是 memory 中。函数修饰符(function modifier)
是什么
在编写函数时,我们通常会收到一些输入,我们需要在处理其余“业务”逻辑之前对这些输入进行某种验证、检查或运行其他逻辑。例如,如果你使用纯 JavaScript 编写,你可能想要检查您的函数接收的是整数而不是字符串。如果它在后端,你可能需要检查 POST 请求是否包含正确的身份验证标头和密码。在 Solidity 中,我们可以通过声明一个称为修饰符(modifier)来执行这些类型的验证步骤,修饰符是是一个类似函数的代码块。修饰符是一段代码,可以在运行主函数(即应用了修饰符的函数)之前或之后自动运行。修饰符也可以从父合约继承。它是避免重复代码的一种方法,方法是提取通用功能放入修饰符中,而修饰符可以在整个代码库中重用。修饰符看起来很像函数。观察修饰符的关键是_(下划线)出现的位置。该下划线就像一个“占位符”,指示主函数何时运行,可以认为是在当前下划线所在的位置插入了主函数。因此,在下面的修饰符代码中,我们运行条件检查以确保消息发送者是合约的所有者(owner),然后我们运行调用此修饰符的函数的其余部分。请注意,单个修饰符可以由任意数量的函数使用。Solidity 中的异常处理
– require/assert/revert
Solidity 中的错误处理可以通过几个不同的关键字和操作来实现。当出现错误时,EVM 将恢复对区块链状态的所有更改。换句话说,当抛出异常并且未在 try-catch 块中捕获时,该异常将在被调用的方法的堆栈中“冒泡”, 并返回给用户。当前调用(及其子调用)中对区块链状态所做的所有更改都将被撤销。在诸如 delegatecall、send、call 等底层函数中有一些例外,其中错误会将布尔值 false 返回给调用者,而不是冒出一个错误。作为开发人员,你可以采用三种方法来处理和抛出错误:require()、assert() 或 revert()。require 语句检查你指定的布尔条件,如果为假,它将抛出带有你提供的字符串或没有说明(如果没有指定)的错误:
function requireExample() public pure { require(msg.value >= 1 ether, “you must pay me at least 1 ether!”); }在继续我们的代码逻辑之前,我们使用 require() 来验证输入、验证返回值和检查其他条件。在此示例中,如果函数的调用者未发送至少 1 个 ETH,该函数将恢复并抛出一条错误消息:“你必须至少支付 1 个 ETH!”。你想要返回的错误字符串是 require() 函数的第二个参数,但它是可选的。没有它,你的代码将抛出一个没有数据的错误——如果没有数据的话,就不会很有帮助。require() 的好处是它会返回未使用的 gas,但在 require() 语句之前使用的 gas 将丢失。这就是我们应该尽早使用 require() 的原因。assert() 函数与 require() 非常相似,只是它抛出类型为 Panic(uint256) 而不是 Error(string)的错误。
contract ThrowMe { function assertExample() public pure { assert(address(this).balance == 0); // Do something. } }assert 也用于略有不同的情况——这些情况下需要不同类型的保护。大多数情况下,你使用 assert 来检查“invariant(不变)”的数据片段。在软件开发中,不变量是一个或多个数据,其值在程序执行时永远不会改变。上面的代码示例是一个微型合约,并不是为了接收或存储任何 ETH 而设计的。它的设计旨在确保它的合约余额始终为零,这就是我们使用 assert 测试的不变量。assert() 调用也用在 internal 函数中。他们可以测试本地状态不包含或不可能的值,但由于合约状态变得“脏”,这些值可能已经改变。正如 require(), assert() 也会回退所有更改。但是在 Solidity 的 v0.8 之前,assert() 用于耗尽所有剩余的 gas,这一点与 require() 不同。通常,你可能会更多地使用 require() 而不是 assert()。第三种方法是使用 revert() 调用。这通常用于与 require() 相同的情况,但使用 revert()的场景中,一般条件逻辑会更复杂。此外,你可以在使用 revert() 时抛出自定义错误。就 gas 消耗而言,使用自定义错误通常可以更便宜,并且从代码和错误可读性的角度来看,自定义错误通常可以提供更多信息。请注意我是如何通过在自定义错误名称前加上合约名称,从而提高其可读性和可追溯性的,通过这种方式我们可以知道是哪个合约引发了错误。
contract ThrowMe { // 自定义错误 error ThrowMe_BadInput(string errorMsg, uint inputNum); function revertExample(uint input) public pure { if (input < 1000 ) { revert ThrowMe_BadInput(“Number must be an even number greater than 999”, input); } if (input < 0) { revert(“Negative numbers not allowed”); } } }在上面的示例中,我们使用了一次 revert 和一个带有两个特定参数的自定义错误,然后我们再次使用 revert 且仅包含一个字符串错误数据。在任何一种情况下,区块链状态都会 revert,未使用的 gas 将返回给调用者。(未完待续…)END▲获取Chainlink官方最新资讯加入 Chainlink官方渠道▼
微博: https://weibo.com/chainlinkofficial
知乎:https://www.zhihu.com/people/chainlink
中文 Twitter: https://twitter.com/ChainlinkCN
Twitter: https://twitter.com/chainlink
中文爱好者电报群:https://t.me/chainlinkfans
Telegram: https://t.me/chainlinkofficial
Discord: https://discord.gg/aSK4zew
GitHub: https://github.com/smartcontractkit/chainlink
SegmentFault:https://segmentfault.com/u/chainlink
QQ群: 6135525
合作联系: china@smartcontract.com
点击“阅读原文”,查看更多
发表回复