ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Solidity入门指南:数据类型、函数与智能合约开发实战

Solidity入门指南:数据类型、函数与智能合约开发实战 Web3系列写到第8篇前7篇我把区块链的底层原理、共识机制、钱包和交易流程捋了一遍从这篇开始真正进入写代码的阶段。今天要啃的是Solidity最基础的三块Hello World、数据类型、函数。这三大件看着简单但如果你是从传统的Java、Python转过来的有几个概念第一遍会很绕尤其是数据位置、函数可见性、gas消耗这些东西我尽量用大白话讲清楚争取你看完就能自己去Remix写合约、编译部署、跑通一次完整的交互。这篇内容不挑基础只要你会一点点编程概念就够了。学完之后你能独立写一个带状态变量的合约部署到测试网还会写函数、改数据、做基础校验。这些本事看着不起眼但它是后面所有合约开发的地基后面的存储设计、权限控制、业务逻辑全都长在这套语法上面。1. 开发环境与第一个Hello World1.1 为什么第一站选Remix很多人学Solidity一上来就问该装什么IDE我的建议永远只有一个入门阶段就用Remix。这个工具说简单点就是一个跑在浏览器里的Solidity集成开发环境不用安装、不用配置环境变量、不用处理npm依赖打开网页就能写简直是新手友好度拉满。你可能会问网上大家不都在用Hardhat和Foundry这些框架吗确实到了做项目、写测试、部署主网的阶段Hardhat几乎是标配。但在你还分不清storage和memory的时候用框架只会增加认知负担。Remix把编译、部署、调用都集成在一个页面里你写一个合约点一下Compile再点一下Deploy就能直接在页面里调用函数看结果这对于理解合约的执行逻辑特别有帮助。另外说一个很多人不知道的点Remix自带的JavaScript VM环境模拟了一条以太坊链你在里面部署合约不用花一分钱也不会真的消耗gas所以你可以在里面随便试错把合约写“烂”了也没关系。这种零成本实验的环境比任何教程都适合用来建立手感。1.2 一个Hello World合约里到底藏了多少东西先别急着复制粘贴我们来拆一个最标准的Solidity Hello World它的完整代码如下// SPDX-License-Identifier: MIT pragma solidity ^0.8.18; contract HelloWorld { string public message; constructor() { message Hello, Web3!; } function setMessage(string memory newMessage) public { message newMessage; } function getMessage() public view returns (string memory) { return message; } }第一行跟逻辑没关系但它是编译器强制要求的存在SPDX许可证标识。Solidity从0.6.8版本开始如果你不在文件开头声明许可证编译的时候会收到警告。这不是什么法律强制的玩意儿更多是社区规范告诉别人你这个代码允许怎么被使用。我一般写MIT简单省事。第二行pragma solidity ^0.8.18;就是版本声明意思是我这个代码要在0.8.18及以上、但小于0.9.0的编译器版本里运行。这个尖括号符号^表示“只锁定大版本”只要不是0.9的大改动小版本升级都兼容。新手最容易踩的坑就是复制代码的时候没注意版本号结果别人用的0.6语法在你的0.8编译器上报错搞得一头雾水。接着是contract HelloWorld这行其实是整个合约开发里真正重要的心智模型。你可以把contract理解成传统面向对象语言里的class它打包了状态变量和行为函数。部署一个合约本质上是把这套代码编译成字节码再通过一笔交易部署到链上链上从此多了一个有自己地址的“智能合约账户”。构造函数constructor()在合约部署时执行一次用来初始化状态。在这个例子里它把message设置成“Hello, Web3!”。后面的setMessage和getMessage就比较直白了一个能修改状态一个只能读取状态后面函数章节我会细讲。1.3 跑通一次完整的部署流程用Remix跑一个合约步骤其实就四步但很多人会卡在第一步找不到编译按钮。我先说界面布局Remix左侧从上到下分别是文件浏览器、搜索、编译、部署与交易、Git等插件。你在文件浏览器里新建一个HelloWorld.sol文件把代码粘贴进去然后点编译插件按一下Compile按钮看到左侧出现绿色对勾就说明编译通过了。编译通过之后切换到部署插件。Environment下拉框里有三个选项JavaScript VM、Injected Provider、Remix VM等入门阶段就选JavaScript VM有的版本叫Remix VM。这里有个细节VM环境旁边有个网络版本选择比如Cancun或Shanghai直接选最新的就行不影响我们这种简单合约。接着点Deploy按钮合约就部署到虚拟机里了页面左下角会出现一个部署好的合约实例。展开这个合约实例你会看到里面列出了所有public状态变量和public函数。比如我们的message是一个public变量它会自动生成一个同名的getter函数所以你会看到一个message按钮还有getMessage按钮点它就能看到返回值setMessage按钮旁边还会有一个输入框填入新字符串再点按钮就完成了一次状态修改。我第一次跑通这个流程的时候最大的震撼在于我点的那一下setMessage本质上就是一笔交易虽然它只是改了一个字符串但这笔交易里包含了发送方地址、接收合约地址、数据、签名等一堆信息。这种“一次函数调用就是一笔链上交易”的思维转变是Web2开发者转入Web3后最需要跨过的门槛。2. Solidity数据类型全解析2.1 基础值类型bool、uint、int、address、bytesNSolidity的数据类型划分和JavaScript那种宽松的隐式转换很不一样它更接近C语言的风格每个变量必须明确自己的类型和存储方式。先看最基础的值类型我列个表把它们串一遍类型说明默认值典型示例bool布尔值true或falsefalsebool isAdmin true;uint无符号整数非负0uint256 count 100;int有符号整数可正可负0int256 temperature -10;address以太坊地址20字节address(0)address owner;bytes1~bytes32定长字节数组0x00...bytes32 hashValue;uint和int这两个类型有许多子类型比如uint8、uint16一直到uint256数字代表位宽。位宽越大能表达的数值范围越大相应的存储成本也越高。日常开发里最常用的是uint256因为EVM的存储槽默认就是256位你说用uint8想省空间在小整数场景下确实能省一点但复杂的打包优化新手先不用管统一用uint256反而不容易出问题。address这个类型是区块链开发绕不开的它存储一个20字节的以太坊地址。这个类型还内置了两个特殊成员.balance可以查询这个地址的以太币余额.transfer可以给地址转币。以前还有.send和.call.value但现在官方推荐用的是.call。转账这部分涉及重入攻击等安全问题后面讲函数和安全性的时候再详细聊。bytes32这类定长字节类型在初学者看来有点奇怪其实你可以把它理解成一段固定长度的原始数据。它能存的不是字符串而是32字节的二进制内容常用来存哈希值、ID、密码学相关的东西。比如keccak256(hello)得到的哈希结果正好可以塞进bytes32变量里。2.2 引用类型从string到mapping如果说值类型是基础砖块那引用类型就是把砖块粘起来的混凝土。Solidity的引用类型主要包括string、bytes、array数组、struct结构体和mapping映射。它们和值类型的核心区别在于值类型的数据是“直接放在变量里的”而引用类型只存一个指向真实数据的位置。以一个常见的场景为例我想记录一个学生的信息可以用结构体把多个字段聚合起来struct Student { string name; uint256 score; bool graduated; } mapping(address Student) public students; function addStudent( address studentAddr, string memory name, uint256 score ) public { students[studentAddr] Student(name, score, false); }这里mapping(address Student)就是Solidity里最常用的键值对结构类似于Java的HashMap或者Python的dict。你可以把address类型的钱包地址作为键把它关联到一个结构体上。这样做的好处是天然实现了“一个用户一条记录”的数据模型而且查询效率高、存储布局清晰。数组和结构体在定义和使用上跟主流语言差不多但有一个巨大的坑藏在数据位置里。你再看上面代码里string memory name这个写法memory就是数据位置的声明。新手99%的编译报错都跟搞混storage、memory、calldata三个数据位置有关所以我单独拿出一节好好讲。2.3 数据位置storage、memory、calldata的区别这可能是Solidity初学者面对的第一座山。你可以这样类比storage是硬盘数据永久放在链上写入成本很高memory是内存函数调用期间存在用完就释放成本相对低一点calldata是函数的只读参数区不可修改也不复制数据最省gas。有了这个类比很多问题就解释得通了。比如函数参数用string memory是因为字符串在函数体内可能需要被修改或复制到状态变量里如果这个字符串只读、不需要改动就可以声明成calldata直接引用原始参数省掉一次内存拷贝。至于storage它可以用来声明指向状态变量的局部引用意思是这个局部变量和链上存储共用同一块区域改它就等于改链上数据。来看一个经典面试题下面的代码会不会报错function getLength(string memory str) public pure returns (uint256) { return bytes(str).length; }答案是能跑但很多人会问为什么长度要先把string转成bytes因为Solidity中的string不像JavaScript的字符串那样直接带length属性你必须转成bytes动态字节数组才能拿到底层字节数。这个细节很能说明问题Solidity对字符串的处理比Web2语言要底层得多你操作的不是“字符”而是“字节”。再说一个数据位置的实战陷阱。如果你在函数里这样写string[] storage items items; // 错误很容易踩坑因为storage同样可以用来声明指向状态变量的引用。正确的做法是string[] storage itemList items; // 假设 items 是状态变量这时itemList和状态变量items指向同一个存储位置修改itemList会直接改动链上数据。新手容易把这个和memory的复制语义搞混改半天发现状态没变或者没改呢状态就变了都是这个原因。2.4 类型转换与常见踩坑Solidity的类型转换分成隐式转换和显式转换两种。隐式转换只有“小的往大的转”才允许比如uint8转uint16因为不会丢数据反过来uint256转uint8会丢精度所以必须要显式声明uint256 large 300; uint8 small uint8(large); // 结果是 44因为 300 被截断了上面的结果是44够反直觉。300的二进制是100101100截断到8位就是00101100也就是十进制的44。这种坑在真实的合约代码里很常见所以我建议非特殊需求别做窄化转换一旦做了就要清醒地知道它会截断。address和bytes之间也有一些转换技巧比如address(uint160(uint256(bytes20(addr))))这种。这些转换在EIP-55校验和、合约交互等场景中会用到新手先混个眼熟即可不需要背下来。真正需要警惕的是显式转换本身不会检查溢出Solidity 0.8之后算术运算溢出会直接报错但类型转换不会所以类型转换前一定要自己确认范围。3. 函数的正确打开方式3.1 一个函数的结构拆解Solidity函数的语法跟JavaScript的箭头函数、Java的方法都不一样它有自己的固定套路。一个完整的函数声明通常包括function关键字、函数名、参数列表注意参数前要加数据位置、可见性关键字、状态可变性关键字、返回值声明、函数体。比如function transferWithCheck( address to, uint256 amount ) public onlyOwner returns (bool success) { require(amount 0, amount must be greater than 0); emit Transfer(msg.sender, to, amount); return true; }这个例子里值得注意的有两点。第一参数列表里的address to, uint256 amount没有写数据位置这其实是允许的因为address和uint256都是值类型不需要指定位置只有引用类型参数才必须写memory或calldata。第二返回值returns (bool success)可以给返回值起名字这样函数体里直接给success赋值也行return true也可以两个写法是等价的。3.2 可见性、状态可变性与修饰符Solidity函数的可见性有四种public、private、internal、external。它们的区别用一个表就能看明白可见性合约内部调用合约外部调用子合约调用public可以可以可以private可以不可以不可以internal可以不可以可以external不可以this可以可以不可以注意external有个隐藏细节它的参数如果是calldata可以避免一次数据拷贝所以外部调用时更省gas。另外public状态变量会自动生成一个同名getter函数所以你在Remix里能直接看到message按钮因为它是public变量。状态可变性有三档view表示只读取状态不修改状态pure表示既不读取也不修改状态纯粹是计算函数什么都不写表示会修改状态。这是一个非常重要的经济问题因为view和pure函数调用不花gas真的不计费而任何会修改状态的函数调用都要消耗gas。修饰符modifier是Solidity里很实用的复用工具用代码展示更容易理解modifier onlyOwner() { require(msg.sender owner, not owner); _; }这个onlyOwner修饰符的意思是调用被它修饰的函数之前先检查调用者msg.sender是不是合约owner如果不是直接报错_;这一行的位置就是“继续执行原函数代码”的占位符。有了它你可以在多个函数前加上onlyOwner来实现权限控制不用在每个函数里重复写一遍校验代码。这在写管理后台、提现函数、紧急暂停功能的时候特别常用算是Solidity开发者最熟悉的一种代码模板。3.3 构造函数、事件与错误处理构造函数是合约部署时的初始化逻辑整个合约生命周期只执行一次。它不能有可见性修饰符如果构造函数里需要设置owner通常这样写address public owner; constructor(address initialOwner) { owner initialOwner; }这里的address initialOwner写在构造函数的参数里部署合约时可以传入这样做的好处是这个合约被部署后owner就能绑定到一个指定地址上了。事件Event是Solidity用来给外部世界发出“通知”的机制。比如前面例子里的emit Transfer(...)它的作用是把这笔转账记录放进交易的日志里。外部程序比如前端DApp可以通过监听这个事件来更新页面不需要主动轮询链上的状态。声明一个事件很简单event Transfer(address indexed from, address indexed to, uint256 amount);indexed关键字表示这个参数可以被索引前端可以根据它来过滤事件。这里有个实用小技巧事件最多只能有三个indexed参数超过的就得换成普通参数。错误处理有三个常用关键字require、revert、assert。日常开发中90%的校验用require就够了它会消耗gas但会退还剩余的gas支持自定义错误消息revert用于更复杂的条件场景可以配合自定义错误类型使用assert用于“绝不应当发生”的内部错误检查如果触发了就说明代码有bug会消耗全部gas。写合约的时候记住一个原则用户输入校验用require合约内部不变量检查才用assert。3.4 返回多个值与常见的返回值坑Solidity函数可以一次返回多个值这在多个数据打包返回的场景下特别顺手。比如function getUserInfo(address user) public view returns (string memory name, uint256 score, bool graduated) { Student storage s students[user]; return (s.name, s.score, s.graduated); }调用端在Remix里可以看到返回的是一个包含三个成员的元组。在Solidity里接收多个返回值时可以只接一部分比如( , uint256 score, ) getUserInfo(addr);空位表示忽略这个返回值。这个特性在解构返回结果时很好用但新手容易犯一个错Solidity没有JavaScript那种把(name, score)直接赋给已存在变量的语法所有返回值都得先给再赋值没有直接解构赋值的写法。还有一点必须提醒函数里对storage引用的操作会直接影响链上状态。比如上面代码里的Student storage s students[user];这里的s不是一份拷贝而是指向了状态变量students[user]的引用你修改s.score就等于是直接改了链上数据。如果你想要的是一份临时修改、不影响链上的副本就得改成Student memory s students[user];。我见过太多人栽在这个区别上改了一通数据以为写进去了结果输出一看原值纹丝不动。4. 新手常见问题与排查实录4.1 常见编译报错速查表我梳理了自己和身边人初学Solidity时遇到最多的报错做成速查表能帮你少走很多弯路报错信息常见原因解决方案DeclarationError: Identifier not found变量名拼错或用了未声明的变量检查大小写和变量定义位置TypeError: Data location must be memory or calldata for parameter引用类型参数没写数据位置给string、bytes、数组等参数补上memory或calldataTypeError: Return argument type ... is not implicitly convertible返回类型不匹配或隐式转换禁止检查返回值类型必要时显式转换CompilerError: Stack too deep局部变量太多EVM栈深度不够减少局部变量、拆分逻辑到子函数UnimplementedFeatureError编译器暂不支持某写法换一种写法比如把复杂结构体拆开处理这里我重点说Stack too deep它是初学者很难排查的问题。EVM栈深度限制在1024但实际一个函数里可用栈槽位很少局部变量一多就容易碰壁。解决办法是尽量把逻辑拆成多个小函数或者把临时变量用结构体打包。这也是为什么大型合约通常会把代码拆分成很多模块不是纯为了代码整洁还是编译器限制倒逼出来的。4.2 数据位置相关的连环坑数据位置问题不是单个报错而是一系列问题的总根源。最常见的场景是你在函数里定义了一个局部字符串数组然后一修改就报错或者改了不生效。其实数组和结构体都有存储位置归属问题局部变量如果不指明数据位置默认按数组是storage、结构体是memory处理但这和赋值的来源冲突时编译器就会直接报错。一个特别典型的报错是TypeError: Data location must be storage, memory or calldata for variable, but none was given.出现这个报错时很多人第一反应是去加storage其实关键要看这个变量要不要修改状态。如果只是临时读一下用memory更安全如果确实要修改链上数据才用storage。还有一个容易忽略的点memory类型的结构体数组其元素之间赋值是引用拷贝还是值拷贝答案是memory到memory是引用拷贝意味着两个变量会共享同一份数据改其中一个另一个也会变。这个行为跟JavaScript的对象拷贝很像刚转过来的开发者常在这里翻车。我的建议是不明确知道自己在做什么就尽量避免直接赋值引用类型的变量宁可显式逐字段赋值。4.3 Gas消耗与for循环的取舍写合约和写普通后端代码最大的区别就是你写的每一条存储操作、每一次循环、每一个事件都是在花真金白银。虽然测试网上的币不值钱但养成“gas敏感”的编码习惯是进入主网开发前必须的。很多新手第一次写完一个合约部署时发现gas很贵或者调用一个函数时发现交易不断失败。最常见的原因就是在合约里用了大循环遍历一个很长的数组。记住一个原则链上数据应该设计成通过key直接读取而不是全量遍历。如果你在设计一个用户列表想找出所有“已毕业”的学生切不要写一个for循环去遍历所有用户因为当用户数量涨到几千上万这笔交易的gas会高到正常人无法承受。正确的设计思路是引入索引器indexer额外用一个mapping记录“用户地址是否存在”或者用一个计数器来维护数量。这个问题在主线项目开发中几乎必然会遇到所以一开始写合约时就要有这个意识。4.4 版本差异与兼容性细节Solidity是一门还在快速演进的语言不同版本的语法差异很大。最典型的变化发生在0.8.0这个版本算术运算溢出会直接回滚而0.8以前是静默溢出pragma的版本声明也更严格constructor取代了旧版本的function ContractName()写法。很多人看老项目的代码会碰到旧语法报错这是正常现象。我的建议是新项目统一用最新的稳定版比如0.8.24以上然后所有依赖库的版本也要跟编译器版本匹配。如果你要参考GitHub上的开源合约一定要注意它的pragma版本在Remix里提前选择好对应的编译器版本否则一编译就是一团乱麻。另外一个细节很多人在Remix里部署合约时会忽略“合约构造函数需要参数”的问题。如果构造函数要求传一个address initialOwner但你直接点Deploy就会报错。解决方法是在Deploy按钮旁边的输入框里填入一个合法的测试网地址再点部署。这个细节对于第一次部署带参合约的人来说很可能卡住好一阵子。5. 从Hello World迈向实战的最后一公里看完上面的内容你已经掌握了Solidity最核心的语法骨架。但我想补充一点看清楚语法和真正写出能上线的合约之间还差着安全意识和设计思维的打磨。比如你会写public函数但你知道什么时候该用external、什么时候该加权限校验吗你知道tx.origin和msg.sender的区别会带来钓鱼攻击吗你知道合约升级的方法和风险吗这些内容一篇教程根本装不下。我个人的体会是学Solidity最适合的方式就是“写一个能跑的小东西然后把它折腾坏”。比如你可以在Remix里写一个简单的计数器合约故意用不合适的可见性修饰符让别人也能改计数再写一个转账函数故意不加require让自己余额变负数看看会发生什么。这种亲手制造失败的过程比背十遍语法都管用。我自己就是在折腾这些“烂合约”的过程中才真正理解了为什么Solidity社区反复强调“代码即审计对象”。下一步你可以试着做这样一个练习写一个投票合约包含候选人列表、投票函数、投票结果查询功能用上今天学过的结构体、数组、mapping、函数可见性和事件。做完这个你对Solidity的基础掌握就算真正过关了。到时候再来聊继承、抽象合约、接口、库函数这些进阶概念你一定会觉得顺滑很多。
返回列表