cryptozombies3
第十章:用view函数节省燃料
太棒了!现在我们的高级僵尸拥有特殊能力了,这能激励主人给它们升级。以后我们还可以添加更多这类功能。
让我们再添加一个函数:我们的DApp需要一个查看用户所有僵尸军团的方法,就命名为getZombiesByOwner吧。
这个函数只需从区块链读取数据,因此可以设为view函数。这正好引出了关于燃料优化的重要知识点:
view函数不消耗燃料
当用户外部调用view函数时,不需要支付任何燃料。
这是因为view函数不会真正改变区块链上的任何数据——它们只是读取数据。所以用view修饰函数时,相当于告诉web3.js只需查询本地以太坊节点即可运行该函数,而无需在区块链上创建交易(交易需要每个节点共同验证且消耗燃料)。
后续我们会讲解如何用自有节点配置web3.js。现在关键要记住:尽量使用只读的外部view函数,可以为用户优化DApp的燃料消耗。
注意:如果view函数在合约内部被非view函数调用,仍会消耗燃料。因为调用它的函数正在创建以太坊交易,仍需所有节点验证。所以唯有外部调用时view函数才是免费的。
实践环节
我们将实现返回用户全部僵尸军团的功能。后续可以在web3.js中调用此功能,在用户资料页展示其完整军团。
该函数逻辑较为复杂,需要分几个章节完成。
- 创建名为
getZombiesByOwner的新函数,参数为_owner(地址类型) - 将其设为
external view函数,以便通过web3.js免燃料调用 - 函数返回类型声明为
uint[](动态数组),数据位置设置为memory - 函数体暂时留空,下一章我们再完善实现
答案:
1 | |
returns返回值要放在view的后面,而不是function的下面,为什么?
您说得对!这个语法确实容易混淆。让我来解释一下Solidity函数声明的结构顺序:
正确的语法顺序是:
1 | |
具体到您的例子:
1 | |
为什么是这个顺序?
function- 函数声明关键字getZombiesByOwner- 函数名(address _owner)- 参数列表external- 可见性修饰符(函数能被谁调用)view- 状态可变性(函数对状态变量的操作权限)returns (uint[] memory)- 返回类型声明
逻辑解释:
- 先定义函数的身份(名字、参数)
- 再定义函数的行为特性(谁能调用、能做什么操作)
- 最后定义函数的产出结果(返回什么)
这种语法设计让函数声明更加清晰:先说明函数是什么,再说明函数能做什么,最后说明函数返回什么。
您提出的问题很好,这确实是Solidity初学者经常困惑的地方!
第十一章:存储操作十分昂贵
在Solidity中,使用存储(storage)是成本最高的操作之一——尤其是写入操作。
这是因为每次写入或修改数据时,该数据都会被永久记录到区块链上。永远存在!全球成千上万的节点都需要将这份数据存储在他们的硬盘上,而且随着区块链的增长,数据量会持续扩大。因此进行存储操作需要付出代价。
为了控制成本,除非绝对必要,否则应避免向存储中写入数据。有时这可能需要采用看似低效的程序逻辑——例如每次函数调用时在内存中重建数组,而不是将数组保存在变量中以便快速查找。
在大多数编程语言中,遍历大型数据集的成本很高。但在Solidity中,如果是在外部view函数中执行此操作,其成本远低于使用存储操作,因为view函数不会让用户支付任何燃料费(而燃料消耗直接关系到用户的真实金钱支出)。
我们将在下一章详细讨论循环语句,不过首先来学习如何在内存中声明数组。
在内存中声明数组
你可以对数组使用memory关键字,以便在函数内部创建一个新数组,而无需向存储中写入任何内容。该数组仅在函数调用期间存在,从燃料成本角度看,这比更新存储中的数组要便宜得多——如果是在外部调用的view函数中,则完全免费。
以下是在内存中声明数组的方法:
1 | |
这只是一个简单的语法示例,在下一章中我们将结合循环语句来探讨实际应用场景。
注意:内存数组创建时必须指定长度参数(本例中为3)。目前它们不能像存储数组那样使用array.push()来调整大小,不过这个特性可能在未来的Solidity版本中有所改变。
实践环节
在我们的getZombiesByOwner函数中,需要返回一个uint[]数组,包含特定用户拥有的所有僵尸。
- 声明一个名为result的uint[] memory变量
- 将其初始化为一个新的uint数组。数组长度应该是该_owner拥有的僵尸数量,我们可以通过映射关系查询:ownerZombieCount[_owner]
- 在函数末尾返回result。目前这还是个空数组,下一章我们将完善其内容。
1 | |
第十二章:For 循环
在上一章我们提到,有时候您会希望在函数中使用 for 循环来构建数组内容,而不是简单地将数组存入存储。
让我们来看看具体原因。
对于我们的 getZombiesByOwner 函数,一个简单的实现方式是在 ZombieFactory 合约中存储一个所有者到僵尸军团的映射:
mapping (address => uint[]) public ownerToZombies
这样每次创建新僵尸时,我们只需使用 ownerToZombies[owner].push(zombieId) 将其添加到该所有者的僵尸数组中。而 getZombiesByOwner 函数就会变得非常简单直接:
1 | |
这种方法的缺陷
这种方法因其简洁性而颇具吸引力。但设想一下,如果我们后续要添加转移僵尸所有权的功能(在后续课程中肯定会实现!),会发生什么情况?
这个转移函数需要:
- 将僵尸添加到新主人的 ownerToZombies 数组
- 从原主人的 ownerToZombies 数组中移除该僵尸
- 将原主人数组中剩余的所有僵尸向前移动一位以填补空缺
- 将数组长度减1
其中第3步在燃气消耗方面代价极高,因为我们需要为每个移动位置的僵尸执行写入操作。如果一个主人拥有20个僵尸并交易掉第一个,我们就需要进行19次写入来维持数组顺序。
由于存储写入是 Solidity 中最耗燃气的操作之一,每次调用这个转移函数都会产生极高的燃气成本。更糟糕的是,根据用户拥有的僵尸数量和被交易僵尸的索引位置,每次调用的燃气消耗量都不相同,导致用户无法预知需要支付多少燃气。
注意:当然,我们可以用数组末尾的僵尸来填补空缺位置,再将数组长度减1。但这样做会导致每次交易都会改变僵尸军团的排序顺序。
由于视图函数在外部调用时不会消耗燃气,我们可以在 getZombiesByOwner 中使用 for 循环遍历整个僵尸数组,构建属于特定主人的僵尸列表。这样我们的转移函数将变得经济很多,因为不需要重组存储中的任何数组——虽然看似绕远路,实际上这种方案的整体成本反而更低。
for 循环的使用
Solidity 中 for 循环的语法与 JavaScript 类似。
来看一个创建偶数数组的示例:
1 | |
该函数将返回一个包含 [2, 4, 6, 8, 10] 的数组。
实践演练
让我们通过编写一个 for 循环来完成 getZombiesByOwner 函数,该循环将遍历 DApp 中的所有僵尸,比对它们的所有者是否匹配,并将匹配的僵尸添加到结果数组中返回。
声明一个名为 counter 的 uint 变量并初始化为 0。我们将用这个变量来跟踪结果数组的索引位置。
声明一个 for 循环,从 uint i = 0 开始,直到 i < zombies.length。这将遍历数组中的每个僵尸。
在 for 循环内部,使用 if 语句检查 zombieToOwner[i] 是否等于 _owner。这将通过比较两个地址来判断是否匹配。
在 if 语句中:
- 通过设置 result[counter] = i 将僵尸 ID 添加到结果数组
- 将 counter 递增 1(参考上文 for 循环示例)
至此,这个函数将返回 _owner 拥有的所有僵尸,且不会消耗任何燃气。
第13章:总结收尾
恭喜你!第3课到此结束。
让我们回顾一下:
- 我们实现了 CryptoKitties 合约的更新方案
- 学习了使用
onlyOwner修饰符保护核心函数 - 了解了 Gas(燃气费)及 Gas 优化相关知识
- 为僵尸添加了等级(levels)和冷却时间(cooldowns)机制
- 实现了僵尸达到特定等级后可更新名称(name)和 DNA 的函数
- 最终完成了返回用户僵尸军团的查询函数
领取你的奖励
作为完成第3课的奖励,你的两只僵尸均已升级!
现在,你在第2课创建的猫咪僵尸「无名」(NoName)已升级至2级,你可以调用 changeName 函数为它命名。从此告别「无名」啦!
快去给你的「无名」僵尸起个名字,然后进入下一章完成本课程的全部内容吧。
https://share.cryptozombies.io/en/lesson/3/share/111111111111111111111111?id=Y3p8NjY1NTA4
第1章:Payable(可接收ETH修饰符)
到目前为止,我们已经介绍了多种函数修饰符。要记住所有内容可能有些困难,因此我们快速回顾一下:
首先是可见性修饰符,用于控制函数的调用时机和范围:
private(私有):仅能被合约内部的其他函数调用;internal(内部):类似private,但继承自该合约的子合约也可调用;external(外部):仅能从合约外部调用;public(公开):可在任意位置调用(内部、外部均可)。
其次是状态修饰符,用于说明函数与区块链的交互方式:
view(只读):调用函数不会保存或修改区块链上的任何数据;pure(纯函数):函数不仅不会向区块链写入数据,也不会从区块链读取数据。注意:如果这两种修饰符的函数从合约外部调用,不会消耗任何Gas(燃气费);但如果被合约内部的其他函数调用,则会产生Gas消耗。
最后是自定义修饰符(我们在第3课中学习过),例如onlyOwner(仅所有者可调用)和aboveLevel(等级达标可调用)等。我们可以通过自定义逻辑来定义这些修饰符对函数的限制规则。
这些修饰符可以在函数定义时叠加使用,示例如下:
1 | |
在本章中,我们将介绍另一个函数修饰符:payable(可支付)。
payable 修饰符
payable函数正是体现 Solidity 与以太坊独特魅力的关键——它们是一种能够接收以太币的特殊函数。
请仔细思考这一点。当您在普通网络服务器上调用 API 函数时,您无法在调用函数的同时发送美元——也无法发送比特币。
但在以太坊中,由于货币(以太币)、数据(交易负载)和合约代码本身都共存于以太坊网络,这使得您在调用函数的同时向合约支付资金成为可能。
这为实现各种精彩逻辑创造了条件,例如要求必须向合约支付特定金额才能执行某个函数。
让我们通过示例来具体理解:
1 | |
这里的 msg.value 是用于查看向合约转入 ETH 数量的内置属性,而 ether 是 Solidity 中的原生单位(1 ether = 10¹⁸ wei)。
具体操作逻辑是:用户会通过 web3.js(即 DApp 的 JavaScript 前端)调用该函数,示例代码如下:
1 | |
注意 value 字段——JavaScript函数调用通过它指定要发送的ETH数量(此处为0.001)。
如果你把交易比作一个信封,函数调用的参数是你放在信封里的信件内容,那么添加 value 就像是在信封里放现金——信件和钱会一起送达收件人(合约)。
注意:如果函数没有标记
payable,却尝试像上面那样发送ETH,该函数会拒绝你的交易。
实战练习
让我们在僵尸游戏中创建一个 payable 函数。
假设游戏有个功能:用户可以支付ETH来提升僵尸等级。这些ETH会存储在你拥有的合约中——这是一个简单的例子,展示了如何在游戏中赚钱!
请按以下步骤实现:
- 定义一个
uint类型的变量levelUpFee,赋值为0.001 ether; - 创建一个名为
levelUp的函数,参数为_zombieId(uint类型),函数可见性为external且标记payable; - 函数内部首先通过
require检查msg.value是否等于levelUpFee; - 然后将对应僵尸的等级加1:
zombies[_zombieId].level++。
关键技术点说明
value字段的作用:
在Web3.js调用合约函数时,value是额外传递的交易参数,用于指定伴随交易发送的ETH数量(单位默认是 wei,需显式用ether单位时要确保Web3库支持单位转换)。payable修饰符的必要性:
合约函数必须标记payable才能接收ETH,否则会触发revert操作,交易失败且ETH会退回发送者。msg.value与levelUpFee的关系:msg.value是实际传入的ETH数量,levelUpFee是合约规定的“升级费用”,通过require强制两者相等,确保用户支付正确金额才能执行升级逻辑。状态修改逻辑:
zombies[_zombieId].level++会修改合约存储的僵尸状态,因此该函数不能标记view或pure,且会消耗Gas(由交易发送者支付)。
第 2 章:提款(Withdraws)
在上一章中,我们学习了如何向合约发送以太币(Ether)。那么,在你发送之后,这些以太币会发生什么呢?
当你向合约发送以太币后,它会被存储在该合约的以太坊账户中,并且会被困在那里—— 除非你添加一个函数来从合约中 ** 提取(withdraw)** 以太币。
你可以通过编写如下函数来从合约中提取以太币:
1 | |
注意,我们在此使用了 Ownable 合约中的 owner() 函数和 onlyOwner 修饰符,前提是该合约已被导入。
需要重点注意的是:你无法向一个地址转账 ETH,除非该地址是 address payable 类型。但 _owner 变量的类型是 uint160(这是 Ownable 合约中对所有者地址的底层存储类型),这意味着我们必须显式地将其转换为 address payable 类型。
一旦你将地址从 uint160 转换为 address payable,就可以使用 .transfer() 函数向该地址转账 ETH 了。其中,address(this).balance 会返回当前合约中存储的 ETH 总余额。例如,如果有 100 个用户各向我们的合约支付了 1 个 ETH,那么 address(this).balance 的值就会等于 100 个 ETH。
你可以使用 .transfer() 函数向任何以太坊地址发送资金。例如,你可以编写一个函数,当用户为某个物品多支付了费用时,将多付的部分退还给 msg.sender:
1 | |
或者在一个包含买家和卖家的合约中,你可以将卖家的地址存储起来,当有人购买他的物品时,就将买家支付的费用转账给卖家:seller.transfer(msg.value)。
这些例子充分展现了以太坊编程的魅力所在 —— 你可以构建这样的去中心化市场,它们不受任何人控制。
实战练习
在我们的合约中创建一个提款函数,功能与上面的「GetPaid」示例完全相同;
过去一年,ETH 的价格上涨了超过 10 倍。因此,虽然在撰写本文时 0.001 ETH 大约价值 1 美元,但如果再上涨 10 倍,0.001 ETH 就会变成 10 美元,我们的游戏成本就会变得高得多。
所以,创建一个允许合约所有者设置 levelUpFee 的函数是个好主意。
请按以下要求实现:
a. 创建一个名为 setLevelUpFee 的函数,参数为 _fee(uint 类型),函数可见性为 external,并使用 onlyOwner 修饰符;
b. 函数内部将 levelUpFee 的值设置为 _fee。
第 3 章:僵尸对战
第 3 章:僵尸对战(Zombie Battles)现在我们已经学习了可支付函数(payable functions)和合约余额(contract balances),是时候为我们的僵尸游戏添加对战功能了!
按照前几章的格式,我们将通过创建一个新文件 / 合约来组织代码,这个新合约将导入(import)之前的合约,并实现攻击功能。
实战练习 (Put it to the test)
让我们回顾一下如何创建一个新合约。重复有助于精通!
如果不记得具体的语法,可以参考 zombiehelper.sol 文件 —— 但请先尝试独立完成,以此来检验你的知识掌握程度。
在文件顶部声明本合约使用的 Solidity 版本为 >=0.5.0 <0.6.0。
从 zombiehelper.sol 导入合约。
声明一个名为 ZombieAttack 的新合约,它继承自 ZombieHelper。目前,让合约体保持为空。
第 4 章:随机数 (Random Numbers)
很好!现在让我们来设计战斗逻辑。
所有优秀的游戏都需要一定程度的随机性。那么,我们如何在 Solidity 中生成随机数呢?
真正的答案是:你无法生成真正安全的随机数。嗯,至少无法安全地做到这一点。
让我们来看看为什么。
通过 keccak256 生成随机数在 Solidity 中,我们能获取的最好的 “随机性” 来源是 keccak256 哈希函数。
我们可以通过类似下面的方式来 “生成” 一个随机数:
1 | |
这个函数的工作原理如下:它会获取当前的时间戳(now)、消息发送者地址(msg.sender)和一个递增的随机数种子(nonce,一个只使用一次的数字,确保我们不会对相同的输入参数两次执行相同的哈希函数)。
然后,它会将这些输入 “打包”(pack)在一起,并使用 keccak 函数将它们转换为一个随机哈希值(random hash)。接下来,它会将这个哈希值转换为一个无符号整数(uint),然后使用取模运算符 % 100 来获取其最后两位。这将为我们提供一个介于 0 和 99 之间的完全随机数。
这种方法容易受到恶意节点的攻击
在以太坊中,当你调用合约的某个函数时,你会将这个调用作为一笔交易广播到网络中的一个或多个节点。网络中的节点随后会收集大量交易,尝试率先解决一个计算密集型的数学问题(即 “工作量证明”,Proof of Work),然后将这组交易连同其工作量证明一起打包成一个区块,发布到整个网络。
一旦某个节点解决了工作量证明,其他节点就会停止尝试,转而验证该节点的交易列表是否有效,然后接受这个区块,并继续尝试解决下一个区块。
这就使得我们的随机数函数存在被利用的漏洞。
假设我们有一个猜硬币合约 —— 猜对( heads )你就能翻倍资金,猜错( tails )你就输掉所有。假设它使用上述的随机函数来决定正反面(随机数 >= 50 为正面, < 50 为反面)。
如果我正在运行一个节点,我可以只将我的交易发布到我自己的节点,而不分享给其他节点。然后我可以调用猜硬币函数,看看自己是否赢了 —— 如果输了,我就选择不将这笔交易包含在我正在打包的下一个区块中。我可以无限次地这样做,直到我最终赢得猜硬币游戏,并且成功打包下一个区块,从而获利。
那么,我们如何在以太坊中安全地生成随机数呢?
由于区块链的全部内容对所有参与者都是可见的,这是一个难题,其解决方案超出了本教程的范围。你可以阅读这个 StackOverflow 帖子获取一些思路。其中一个想法是使用 “预言机(oracle)” 来访问以太坊区块链之外的随机数函数。
当然,由于网络上有数万个以太坊节点在竞争打包下一个区块,我成功打包下一个区块的概率极低。我需要花费大量时间或计算资源才能通过这种方式获利 —— 但如果回报足够高(比如我可以在猜硬币函数上下注 1 亿美元),那么对我来说发动攻击就是值得的。
因此,尽管这种随机数生成方法在以太坊上并非绝对安全,但在实践中,除非我们的随机函数涉及大量资金,否则你的游戏用户可能没有足够的资源来攻击它。
由于我们在本教程中只是为了演示目的构建一个简单的游戏,并且不涉及真实资金,所以我们将接受使用这种易于实现的随机数生成器的 tradeoffs(权衡取舍),同时清楚它并非完全安全。
在未来的课程中,我们可能会介绍使用预言机(一种从以太坊外部获取数据的安全方式)来从区块链之外生成安全的随机数。
实战练习
让我们实现一个随机数函数,用于决定我们游戏中战斗的结果,尽管它并非完全不会受到攻击。
给我们的合约添加一个 uint 类型的变量 randNonce,并将其值设置为 0。
创建一个名为 randMod(随机取模)的函数。它是一个 internal(内部)函数,接收一个名为 _modulus 的 uint 参数,并返回一个 uint。
函数内部首先要递增 randNonce(使用 randNonce++ 语法)。
最后,它需要(在一行代码中)计算 abi.encodePacked(now, msg.sender, randNonce) 的 keccak256 哈希值,并将其类型转换为 uint,然后返回该值对 _modulus 取模的结果(… % _modulus)。
(呼!这有点拗口。如果没跟上,只需回顾一下我们之前生成随机数的例子 —— 逻辑非常相似。)