Solidity基础

官方文档:Home | Solidity Programming Language (soliditylang.org)(有中文译版)

前言

本来是准备把这一篇全部移入go语言–区块链学习(三)中的,考虑到排版问题,全放一起可能会显得十分拥塞,也不方便整体的可读性,影响阅读体验,所以博主将这一知识点的分享提取出来,单独成块,作为一篇博客发表。

在进入这一篇的学习前,您需要确保你的操作系统上已经安装了Remix以及MetaMask,并熟练掌握其使用方法。

如果没有,请移步到下面链接,确保您以及做好了充足的准备,迎接接下来的Solidity的学习。

MetaMask的安装及使用:MetaMask安装及使用(全网最全!!!)_sepoliaeth水龙头-CSDN博客

Remix-Desktop的安装:Remix-Desktop安装-CSDN博客

此文章参考了课程:freeCodeCamp S2 - YouTube( Lesson 2 Pt. 1~Lesson 4 Pt. 18)

(如果可以的话,推荐您去看视频,如果不想看视频的话,那就请您继续看下去吧)

Remix-Desktop介绍

Remix-Desktop是Remix的桌面版应用程序,它提供了与Remix网页版类似的功能,但是可以在本地计算机上运行。与Remix网页版不同的是,Remix-Desktop不需要通过浏览器访问,而是可以通过下载和安装应用程序来使用。

Remix-Desktop包含了Solidity编译器、交互式控制台、调试器等工具,可以帮助开发人员更加高效地进行以太坊智能合约的开发和调试。同时,Remix-Desktop还支持与以太坊客户端的连接,可以直接在本地计算机上与以太坊网络进行交互。

开始使用Remix

接下来我们开始使用Remix,首先打开我们的Remix,你会看到如下界面,如果你对中文情有独钟可以,可以在此设置语言。(博主推荐用英文模式,没有为什么)

在这里插入图片描述

然后,与浏览器版的Remix很大的不同,在Desktop版中我们可以将写的Solidity代码存储到本地,不会出现下次打开可能会造成数据丢失的情况,这无疑对我们的用户体验来说十分友好。

而我们初始化的界面,文件浏览器默认路径是Remix所在根目录路径,所以,为了可以更好地管理我们的代码,我们需要自己手动设置一个工作区,里面将用于存放我们的代码,再将浏览器目录切换至工作区。

在这里插入图片描述

现在,我们已经配置好了一个空白的Remix,接下来,我们将完成你的第一个智能合约的编写。

首先,我们创建一个合约的文件夹,名称为contracts,然后,点击合约文件夹,新建文件,会有小的输入栏跳出来,你可以输入文件名,输入SimpleStorage.sol,.sol就是Solidity文件的后缀名。(.sol可以不输,默认是.sol)

请添加图片描述

Solidity是智能合约的主要编程语言,虽然还有一些别的编程语言,但是Solidity目前还是最主流的。

然后右边会有SimpleStorage.sol文件,可以开始写Solidity代码。

你可以先打开左边的Solidity的图标,可以看到编译Solidity代码所需的参数。

在这里插入图片描述

好,接下来,我们正式写代码。

在任何一个Solidity智能合约中,你首先需要的就是Solidity的使用版本,它应该被标注在Solidity代码的最上面,Solidity是一个更新频率很高的语言,和别的语言相比,它总会有新版本,所以我们需要告诉代码,要用哪个版本,我们通Pragma Solidity + 版本号;来约定版本号。(Solidity语句以 ; 结尾)

版本号有这么一些书写方式:

^0.8.0:表示支持0.8.0及以上的版本(同 >= 0.8.0)

>= 0.8.0 <= 0.9.0:表示支持0.8.0及以上0.9.0及以下的版本

0.8.22:表示只支持0.8.22版本

同时,在代码最上方,你可以加入SPDX-License-Identifier,最然这个是可选的,但是没有的话,有些编译器会出现警告,这个会定义License和代码分享规则,这里不更多介绍,如果需要解释,可以阅览:What is a software license? 5 Types of Software Licenses Explained | Snyk

为了标注License,需要在这里写SPDX-License-Identifier,我们这里选择MIT,MIT是限制最少的License之一,我们会在大多数的代码中使用MIT协议。

接下来,我们输入contract,开始定义智能合约,contract是Solidity的关键字,它告诉编译器后面的代码是来定义智能合约的。(类似面向对象语言中的class关键字)

然后我们给智能合约起一个名字,就叫它SimpleStorage,再加入一对{ },在花括号中的代码就是SimpleStorage智能合约的内容。

当我们写完这些的时候,就可以到编译界面(点击Solidity图标),点击编译(Compile),或者直接按Crtl+S,也是可以的。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract SimpleStorage {

}

然后就可以看见Solidity图标的上面出现了绿色对勾,绿色的对勾表示我们的代码成功编译,并且没有错误。

在这里插入图片描述

假设现在部署这个合约,它就会是一个有效的合约。

数据类型

Solidity中最基础的数据类型包括:

  1. 布尔类型(bool):布尔类型表示真或假的值。
  2. 整数类型:整数类型分为有符号和无符号两种。有符号整数类型包括int8、int16、int32、int64等,而无符号整数类型包括uint8、uint16、uint32、uint64等。这些类型表示不同位数的整数。
  3. 固定点数类型:固定点数类型用于处理小数。例如,fixed和ufixed表示固定点数,后面可以跟着小数点的位数。
  4. 地址类型(address):地址类型表示以太坊网络上的账户地址。
  5. 字节类型(bytes):字节类型表示一组字节数据。例如,bytes32表示32个字节的数据。
  6. 动态字节数组类型(bytes):动态字节数组类型与字节类型类似,但其长度可变。
  7. 字符串类型(string):字符串类型表示文本数据。
  8. 枚举类型(enum):枚举类型用于定义一组离散的可能取值。

现在,我们只介绍其中几种。

代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract SimpleStorage {
    // boolean, uint, int, address, bytes
    bool hasFavoriteNumber = true;
    uint256 favoriteNumber = 5;
    string favoriteNumberInText = "Five";
    int256 favoriteInt = -5;
    address myAddress = 0x4b13f21791e60BBce7003c4315ec965e62c5a52F;
    bytes32 favoriteBytes = "cat";
}

其中uint、int、bytes后面都可以跟上具体的数字,表示分配空间的大小,比如uint8,就是分配了8个bit(这里就不解释一些计算机的基础知识了,默认大家至少学过C/C++、Java、Golang、Python中的一种),这个数字可以一直大到256,如果不知道被赋值的数字多大,默认就是uint256。

通常,把分配空间显式地写出来是一个好习惯,所以这里不把uint256缩写为uint,其他数据类型同理。

如果你想了解更多的数据类型和它们的特性,请查阅Solidity官方文档,跳转链接就在文章开头。

在Solidity中,如果不给变量赋值,它就会有一个默认值是null的值,比如uint256在Solidity中的默认值是0。

代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract SimpleStorage {

    // This gets initialized to zero!
    // <- This means that this section is a comment!
    uint256 favoriteNumber;
}

补充:

数据结构的默认值

在 Solidity 中,变量和状态变量都有默认值。这些默认值取决于变量的类型。

对于各种数据类型,默认值如下:

  • bool类型的默认值是 false。
  • 整数类型(包括 uint 和 int)的默认值是0。
  • 地址类型(address)的默认值是0x0000000000000000000000000000000000000000。
  • 字符串类型(string)的默认值是空字符串""。
  • 动态数组(包括字符串数组)和映射(mapping)的默认值是一个空的、长度为0的集合。

对于结构体和枚举类型,默认值是其成员变量类型的默认值。

需要注意的是,局部变量在声明时不会自动被赋予默认值,而是由开发者手动初始化。而状态变量在合约部署时会被自动赋予默认值。

代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract MyContract {
    bool public myBool;        // 默认值为 false
    uint public myUint;        // 默认值为 0
    address public myAddress;  // 默认值为 0x0000000000000000000000000000000000000000
    string public myString;    // 默认值为 ""
    uint[] public myArray;     // 默认值为一个空的、长度为 0 的数组
}

如果你希望在声明变量时为其指定一个非默认值,可以在声明时赋初值,或者在构造函数中进行初始化。

Solidity中的注释

//:单行注释

/**/:模块注释

函数

现在,让我们创建一个函数,函数或者方法,指的是独立模块,在我们调用的时候会执行某些指令。

Solidity的函数和常见的编程语言类似,函数通过function关键字表示。

这里我们创建一个名字叫store的函数,这个函数会把favoriteNumber改成一个新的值,要改变的数字,是传给store函数的参数,所以我们定义store函数,接受uint256的参数,参数名字是_favoriteNumber,定义为public函数,这个在后面讲到,然后在函数中,我们将favoriteNumber赋值为传入的参数。

代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract SimpleStorage {

    // This gets initialized to zero!
    // <- This means that this section is a comment!
    uint256 favoriteNumber;

    function store(uint256 _favoriteNumber) public {
        favoriteNumber = _favoriteNumber;
    }
}

现在store函数会接受一个参数,然后将参数赋值给favoriteNumber。

为了看看它实际运行结果,我们把合约部署在一个测试环境上,即部署在本地网络上,或者叫Remix VM(以前叫JavaScript VM,如果你看一些课程或者其他教程,如果看见的是JavaScript VM,则不用疑惑,它们是一个东西)。

首先第一步就是Ctrl+S,或者点击Solidity图标,再点击Compile,编译一下我们的Solidity代码。

等待编译成功后,点下面的按钮,部署和发送交易区域,在部署和发送交易部分,有很多设置选项。

首先第一个环境选择Remix VM (Shanghai),Remix VM表示合约将被部署到本地的Remix(JavaScript)虚拟机上,Remix VM是本地测试用的区块链,在上面可以快速模拟交易,不需要等待测试网的流程。
在这里有一些账户,运行Remix VM的时候,我们有很多账户可以部署,每个账户中都有100个以太币,这些账户和MetaMask中账户的类似,区别是,这些是在Remix VM中的测试以太币。
在部署的合约的交易中可以设置gas limit,同时可以选择要部署的合约,现在我们只有SimpleStorage合约,也正是要部署的。
点击Deploy按钮来部署合约,滑到最下方,可以看到合约已经被部署了。

在这里插入图片描述

有一个橘黄色的按钮store,参数是uint 256 _favoriteNumber。
在测试环境中,每个合约都有一个地址,就像每个账户都有地址一样,这里的0XD91…39138表示的就是SimpleStorage合约的地址。

将右下方的窗口调大,这里可以看到一些部署细节,点开下拉菜单可以看到很多信息,有一些你应该知道,是一些熟悉的关键字,像是状态、交易哈希、from、to等等(如果不熟悉,请转移到MetaMask安装及使用中查看,链接在文章最上方的前言中)。

在这里插入图片描述

部署合约其实就是发送一个交易,我们在区块链上做任何事情,修改任何状态,就是在发送一个交易。部署一个合约就修改了区块链,让链上拥有这个合约,如果我们在Sepolia,Goerli或者以太坊主网上发送这个交易,我们要支付gas来部署合约。

这些数据模拟在真正网络部署交易的数据,要支付多少gas、交易哈希、from、to和其他所有数据。但是现在这些数据是Remix VM伪造出来的。

这个橘黄色的按钮。代表了我们刚创建的函数store。在这里输入一些数据,比如123,然后点击按钮,我们就在Remix VM测试环境中执行了一个交易,把123传入了favoriteNumber中。

在这里插入图片描述

接下来往上滑,查看账户,可以看到账户的余额中稍微少了一点以太币。这是因为我们部署合约的时候以及调用合约的时候用了一些gas。

然后我们可以多试几次,比如输入678,然后点击store可以看到发了一笔交易,在favoriteNumber中存储了678。这里你就可能会有疑惑,favoriteNumber改变了,但是我们看不到(请忽略右边的decoded input),怎么知道favoriteNumber变了呢?

可见度标识符

函数和变量有四种可见度标识符public、private、internal和external。

  1. public:public 是最高级别的可见度标识符,表示变量、函数或合约对内外部都可见。公共状态变量可以被任何人读取,并且公共函数可以被外部调用者调用。公共函数和状态变量的访问可以通过合约地址直接进行。
  2. private:private 是最低级别的可见度标识符,表示只有当前合约内的其他函数才能访问该变量或函数。私有状态变量只能在当前合约中访问,私有函数只能被当前合约的其他函数调用。私有状态变量和函数对外部调用者是不可见的。
  3. internal:internal 表示内部可见性,表示只有当前合约及其派生合约内的其他函数才能访问该变量或函数。内部状态变量和函数可以在当前合约及其派生合约中访问,但对外部调用者不可见。
  4. external:external 可以用于函数,表示该函数只能通过外部消息调用。外部函数只能被其他合约调用,而不能在当前合约内部直接调用。此外,外部函数不能访问合约的状态变量,只能通过参数和返回值进行数据交互。

如果没有显式指定变量的访问修饰符,则默认为internal,而internal关键字表示,它只对本合约和继承合约可见。

现在这个favoriteNumber变量被设置为了internal,所以我们看不到它。

现在我们讨论如何看到它,为了让我们看到它,现在把它设置为public重新编译。

代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract SimpleStorage {

    // This gets initialized to zero!
    // <- This means that this section is a comment!
    uint256 public favoriteNumber;

    function store(uint256 _favoriteNumber) public {
        favoriteNumber = _favoriteNumber;
    }
}

然后进入部署区域,点击下面的X,把旧的合约删掉。当然只是从这个窗口删掉,但是没有从区块链删掉,因为区块链是不可更改的。当然因为这个是测试环境,所以只是某种程度上不可更改。编译,然后重新部署。

在这里插入图片描述

然后发现在新的合约中有两个按钮。

其中一个橘黄色按钮是函数,还有一个favoriteNumber的按钮,这个按钮代表favoriteNumber变量,就像一个显示变量值的函数(就比如Java里面的getter函数),实际上确实就是一个getter函数,此章节后面将会详细说明。

如果现在点击这个按钮,会显示什么?因为favoriteNumber初始化的默认值是0,点击一下,我们可以看到显示的是0。现在就是说这个uint256数据类型存储的数值是0。如果现在通过store函数把变量改为678,再点击favoriteNumber按钮,可以看到数值更新为678。

在这里插入图片描述

补充:

public关键字

当你在Solidity中使用public关键字修饰一个状态变量时,Solidity编译器会自动生成一个类似getter函数的方法来允许外部调用者读取该变量的值。

这个自动生成的getter函数会使用与状态变量同名的函数名,并且没有参数。它的返回值类型与状态变量的类型相匹配。通过调用这个getter函数,外部调用者可以获取到状态变量的当前值。

例如,当你声明一个public的状态变量uint256 public myNumber时,Solidity编译器将会自动生成一个名为myNumber()的函数,用于获取myNumber的值。

代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract MyContract {
    uint256 public myNumber;
}

外部调用者可以通过合约地址调用这个自动生成的getter函数来获取myNumber的值。

需要注意的是,public关键字只会生成一个getter函数用于读取状态变量,而不能直接修改状态变量的值。如果你希望外部调用者能够修改状态变量的值,你可以使用setter函数或将状态变量声明为可写入的。

状态变量和局部变量

在Solidity中,状态变量是指在智能合约中永久存储的变量。它们存储在以太坊区块链上,并且对于每个合约实例都有唯一的值。状态变量定义在合约的顶层范围内,可以在整个合约中被访问和修改。

在 Solidity 中,局部变量是指在函数内部声明并且只在函数作用域内可见的变量。局部变量的作用域通常仅限于声明它们的函数内部,在函数执行完成后即被销毁,不会永久存储在区块链上。

简单理解就是全局变量和局部变量,函数外面的是全局变量(状态变量),函数里面的是局部变量。

作用域

在 Solidity 中,变量和函数可以拥有不同的作用域,作用域决定了这些变量和函数的可见性和访问权限。

以下是 Solidity 中常见的作用域:

  1. 全局作用域:在合约的整个范围内可见的变量和函数属于全局作用域。这些变量和函数可以被合约内的任何地方访问。
  2. 合约作用域:在合约内部声明的变量和函数具有合约作用域,它们只能在声明它们的合约内部访问。
  3. 函数作用域:在函数内部声明的变量具有函数作用域,只能在该函数内部访问。这些变量通常被称为局部变量。
  4. 事件作用域:在 Solidity 中,事件也有其特定的作用域。事件在声明它们的合约内部可见,可以被合约内的任何函数调用来触发。

在 Solidity 中,作用域非常重要,因为它决定了变量和函数的可见性和生命周期。合理使用作用域可以提高代码的可读性,降低变量冲突的可能性,并帮助开发者更好地组织和管理代码。

Solidity的作用域和其他高级编程语言的作用域类似,这里不做更多介绍,可以参考下面的错误代码。

错误代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract SimpleStorage {

    // This gets initialized to zero!
    // <- This means that this section is a comment!
    uint256 public favoriteNumber;

    function store(uint256 _favoriteNumber) public {
        favoriteNumber = _favoriteNumber;
        uint256 testVar = 5;
    }

    function something() public {
        testVar = 6; // ??
    }
}

gas消耗

每次调用这个store函数,我们都会发送一个交易。因为每次在更改区块链状态的时候,我们都会发送交易,可以在右下角Remix的日志区域,查看交易细节。

你可以看到交易消耗了多少gas,可以看到这里的数字比发送交易所用到的21000 gas要多的,那是因为这里的操作会消耗更多的计算量。

在这里插入图片描述

实际上我们在这里存储了一个数字,现在如果我们在函数中做更多的操作会发生什么?除了在这里存储数据外,我们在存储变量后更新这个变量,让favoriteNumber加1。因为我们加了这样一个操作,这个函数将消耗更多的计算量。

代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract SimpleStorage {

    // This gets initialized to zero!
    // <- This means that this section is a comment!
    uint256 public favoriteNumber;

    function store(uint256 _favoriteNumber) public {
        favoriteNumber = _favoriteNumber;
        favoriteNumber = favoriteNumber + 1;
    }
}

然后我们现在重新编译删掉旧合约,然后重新部署,然后再次输入678,然后查看交易细节,我们可以发现执行交易的消耗的gas变得更多了。那是因为我们做的事情变多了,这个函数消耗的计算量变多了。

43730 gas -> 44127 gas

在这里插入图片描述

每个区块链计算gas的方式不同,但最简单的理解是,做越多的操作,消耗更多的gas。

view和pure

我们给favoriteNumber加上public,就相当于给它创建了gettee函数。

我们可以创建一个函数来返回favoriteNumber,模拟被自动创建的getter函数,函数名是retrieve,可见标识符是public,返回值为uint256,返回favoriteNumber。

代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract SimpleStorage {

    // This gets initialized to zero!
    // <- This means that this section is a comment!
    uint256 public favoriteNumber;

    function store(uint256 _favoriteNumber) public {
        favoriteNumber = _favoriteNumber;
    }

    // view, pure
    function retrieve() public view returns(uint256) {
        return favoriteNumber;
    }
}

Ctrl+S来编译,或者在编译页面点击编译。然后在部署页面,删除上一个合约,部署新的合约。

这里多了一个retrieve函数,会返回和favoriteNumber一样的数值。把变量更新为678,然后点击favoriteNumber和retrieve,它们都会返回678。正如你在这里看到的,这两个函数是蓝色的,但是store是橘黄色的。

在这里插入图片描述

它们的区别是什么?关键在这里的view关键字。

Solidity中有两个关键字,标识函数的调用不需要消耗gas。这两个关键字是view和pure。

如果一个函数是view函数,意味着我们只会读取这个合约的状态。例如,retrieve函数只读取favoriteNumber的值。

view函数不允许修改任何状态。你在view函数中,不能修改任何状态。

(为了不影响阅读体验,关于pure的部分移到了此章节后面)

接下来,我们接着前面的内容,在下面的控制台,如果我调用retrieve函数会有一个call。与我们调用store函数不同,store函数调用后会有绿色的对勾和交易哈希,但是在call中没有哈希和绿色对勾。

因为点击蓝色按钮不发送交易,我们只是在链下读取,读取数值。

然而当你查看call的细节信息时,有一个执行消耗。

这是什么意思?这里写了消耗只有在被合约调用时才会计算在内。也就是如果一个要改变区块链状态的函数调用了类似retrieve这种view或者pure函数才会消耗gas。

在这里插入图片描述

例如,store不是view函数,他在某处调用了retrieve,那它就要支付retrieve的gas。

代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract SimpleStorage {

    // This gets initialized to zero!
    // <- This means that this section is a comment!
    uint256 public favoriteNumber;

    function store(uint256 _favoriteNumber) public {
        favoriteNumber = _favoriteNumber;
        retrieve();
    }

    // view, pure
    function retrieve() public view returns(uint256) {
        return favoriteNumber;
    }
}

因为读取区块链的信息消耗了计算量和gas。

调用view函数是免费的,除非你在消耗gas的函数中调用它。

我们可以编译,删除,部署,调用store,输入678,执行,查看它的消耗,发现加了retrieve()这行代码后,store函数的gas消耗更多了。

在这里插入图片描述

在这里插入图片描述

补充:

pure函数也不允许修改状态,我们也不能在pure函数中修改状态,但是pure也不允许读取区块链数据,所以我们也不能读取favoriteNumber的值。

通过pure函数,你想做的事情可能是这样的。在pure函数中返回1+1的结果。返回值是uint256,类似于这样的东西。可能是常用的方法,或者某个不需要读取数据的算法。如果我们调用view或者pure函数,是不需要支付gas的。因为只是读取观念数据,记住,只有更改状态的时候才支付gas,发交易。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract SimpleStorage {
    function add() public pure returns (uint256) {
        return(1 + 1);
    }
}

结构体和数组

现在我们的合约已经很不错了,他允许我们存储单一一个喜欢的数字,但如果我们想要存储一组喜欢的数字呢?或者我们想存储一大批不同的人物,他们都喜欢不同的数字,那我们该怎么做?

方法之一是,我们可以创建一个关于人物的结构体(struct),或者说我们在Solidity中创建一个新的类型,我们可以创建一个人物对象,其中包含某人的名字以及他们喜欢的数字。

我们创建一个被称为People的新类型。就像uint25、boolean或者string,就像我们创建的uint256 public favoriteNumber一样,我们可以对People做相同的事情。

我们可以写下People public,把它命名为person。这样我们就创建了一个新的people,并将其分配给person这个变量,后面再加上(),表示我们在创建一个新的People类型的变量,里面添加{},表示参数是一个结构体,然后赋值。

代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract SimpleStorage {

    // This gets initialized to zero!
    // <- This means that this section is a comment!
    uint256 favoriteNumber;
    People public person = People({favoriteNumber: 2, name: "scc749"});

    struct People {
        uint256 favoriteNumber;
        string name;
    }

    function store(uint256 _favoriteNumber) public {
        favoriteNumber = _favoriteNumber;
        retrieve();
    }

    // view, pure
    function retrieve() public view returns(uint256) {
        return favoriteNumber;
    }
}

然后我们来编译部署一下,因为它是一个public变量,所以它会有一个被称为person的getter函数,我们点击一下person,会看到我们的person对象。

在这里插入图片描述

它的favoriteNumber是2,name是scc749,你可以看到成员变量的左边有个0和1,它们表示不同变量的索引(index),在计算机科学中,计数通常从数字0开始,每当您往solidity的列表变量中放入对象,它们都会自动编上索引,所以在这里,favoriteNumber的索引是0,name的索引是1(感兴趣的小伙伴还可以自己搜寻一下solidity中存储槽(storage slot)的定义与作用)。

现在,我们有了一个People结构体,将favoriteNuber和name关联起来,但如果我们想要一堆人呢?

一种方法是重复声明变量,person1,person2等等,但是这样很麻烦。

更好的创建列表的方法是使用一种被称为数组(array)的数据结构,数组是存储列表,或者说存储一系列对象的一种方式,创建一个数组和初始化其他类型没什么区别,先声明对象类型,然后是对象的可见性,最后是变量名,我们需要对数组做同样的事情。

方括号表示我们想要一个包含People类型的数组,我们让它的可见性是public,然后命名为people。

代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract SimpleStorage {

    // This gets initialized to zero!
    // <- This means that this section is a comment!
    uint256 favoriteNumber;

    struct People {
        uint256 favoriteNumber;
        string name;
    }

    // uint256[] public favoriteNumbersList;
    People[] public people;

    function store(uint256 _favoriteNumber) public {
        favoriteNumber = _favoriteNumber;
        retrieve();
    }

    // view, pure
    function retrieve() public view returns(uint256) {
        return favoriteNumber;
    }
}

编译一下,删掉旧合约,然后重新部署。

在这里插入图片描述

现在我们就有了一个蓝色的people按钮在这里,需要提醒的是,因为它是public变量,所以它会自动获得一个view函数,也就是这个蓝色的按钮,并且,现在它不仅仅是一个单独用于显示数值的按钮,它还有一个可以填写的表格,它需要一个uint256作为它的输入参数。

现在,不管您往这个格子里面输入什么,它都不会有任何反应,这是因为我们的people数组或者说我们的people列表,现在还是个空列表,这里的输入值其实就是输入你想要获得的那个对象的索引。

接下来我们来实现给这个数组添加内容,这种类型的数组就是所谓的动态数组(Dynamic Array),因为我们在初始化这个数组的时候并没有规定它的大小。

如果我们给这个People数组的方括号里添加一个数字5(People[5] public people;),就意味着这个People列表,或者说数组,最多只能放进去五个People对象,如果我们不给它设定大小,那就表明它可以是任意大小,并且数组的大小会随着我们添加和减少People而增大和减小。

所以这里我们使用动态数组,因为我们希望可以添加任意数量的People到这个数组中去,所以让我们来创建一个函数,用于往people数组中添加People。

我们来写下这个函数,addPerson,我们让它接收string memory _name,作为输入参数(下一章节会解释这是什么意思),然后是uint _favoriteNumber,我们让它成为一个public函数,我们要做的就是对这个people对象调用push函数,所以我们写下people.push,然后我们要创建一个新的person,它将接收_favoriteNumber和_name。

代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract SimpleStorage {

    // This gets initialized to zero!
    // <- This means that this section is a comment!
    uint256 favoriteNumber;

    struct People {
        uint256 favoriteNumber;
        string name;
    }

    // uint256[] public favoriteNumbersList;
    People[] public people;

    function store(uint256 _favoriteNumber) public {
        favoriteNumber = _favoriteNumber;
        retrieve();
    }

    // view, pure
    function retrieve() public view returns(uint256) {
        return favoriteNumber;
    }

    function addPerson(string memory _name, uint _favoriteNumber) public {
        people.push(People(_favoriteNumber, _name));
    }
}

这个大写的People表示的是这个名为People的结构体(struct),然后一个新的People获得了_favoriteNumber和_name,这里小写的people指的是这个的数组,所以我们写下的就是,我们的数组+点+push,push基本上就是添加的意思。

还有一种方式,就是我们先创建一个People类型的变量,所以我们就可以这样写,People newPerson等于People,然后和之前的做法一样,把内容放进括号里。

    function addPerson(string memory _name, uint _favoriteNumber) public {
        People memory newPerson = People({favoriteNumber: _favoriteNumber, name: _name});
        people.push(newPerson);
    }

如果您的代码中没有memory,试图保存,就会收到错误提示,变量的数据位置必须是"storage",“memory”,或者"calldata",需要在此处添加关键字memory,下一章我们将会解释这是什么意思。

Memory,Storage & Calldata

在 Solidity 中,有以下几种数据存储位置:

  1. 栈(Stack):栈是一种临时存储区域,用于存储局部变量和函数参数。在函数执行期间,栈上的数据会被分配和释放,当函数执行完成时,栈上的数据也会被销毁。
  2. 内存(Memory):内存是一种临时存储区域,用于存储动态分配的数据,比如动态数组和字符串。与栈不同,内存中的数据不会随着函数执行的结束而销毁,需要手动清除。在函数调用期间,内存中的数据可以被读取和修改。
  3. 存储(Storage):存储是永久存储在区块链上的位置,用于存储合约的状态变量。存储中的数据会一直保存在区块链上,直到合约被销毁。存储是最昂贵的一种存储位置,因为它需要永久存储在区块链上,并且对存储操作收费。
  4. 调用数据(Calldata):调用数据是用于存储外部函数调用的参数和返回值的位置。在函数调用期间,输入参数会被复制到调用数据中,函数执行完成后,返回值也会被写入调用数据中。
  5. 代码(Code):代码用于存储合约本身的字节码,即合约的函数实现、逻辑等内容。
  6. 日志(Logs):日志用于记录合约的事件和状态变化,可以通过日志来实现合约的事件通知和审计功能。

本章节三个最重要的,就是Calldata,Memory和Storage,这是一个稍微进阶的知识点,所以,如果你第一次没有完全掌握它,那也完全没关系。

可以类似记忆:

calldata类比于其他语言中的只读参数,例如在C#中的const参数或在函数调用中传递的不可变数据;

memory可以类比于其他语言中的堆内存,例如在C/C++中使用malloc()或new关键字分配的内存,或者在Python中的临时变量;

storage可以类比于其他语言中的持久化存储,例如在Java中的实例变量或数据库中的表字段。

代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract SimpleStorage {

    // This gets initialized to zero!
    // <- This means that this section is a comment!
    uint256 favoriteNumber;

    struct People {
        uint256 favoriteNumber;
        string name;
    }

    // uint256[] public favoriteNumbersList;
    People[] public people;

    function store(uint256 _favoriteNumber) public {
        favoriteNumber = _favoriteNumber;
        retrieve();
    }

    // view, pure
    function retrieve() public view returns(uint256) {
        return favoriteNumber;
    }

    function addPerson(string memory _name, uint _favoriteNumber) public {
        people.push(People(_favoriteNumber, _name));
    }
}

storage变量存在于正在执行的函数之外,尽管我们在上面没有指定favoriteNumber,但我们的favoriteNumber被自动分配为一个存储(storage)变量,因为它没有在这些函数中明确定义。

calldata和memory意味着这个变量只是暂时存在,所以addPerson()函数参数中的_name变量仅在调用此函数的transaction交易期间暂时存在。

因为在这个函数运行后,我们就不再需要这个_name变量了,我们可以把它作为memory,也可以把它作为calldata,如果你最终不修改这个_name,你可以把这个参数作为calldata。

    // calldata, memory, storage
    function addPerson(string memory _name, uint _favoriteNumber) public {
        _name = "cat";
        people.push(People(_favoriteNumber, _name));
    }

上面的代码运行是没有问题的,如果把memory改成calldata,编译就会发生错误,因为calldata变量不可被修改的临时变量,memory是可以被修改的临时变量,而storage是可以被修改的永久变量。

尽管上面说实际上有6个地方可以让我们访问和存储信息,但我们不能说一个变量是Stack、Code或Logs,我们只能说是Memory、Storage或Calldata。

接下来,我们来分析另一个问题,为什么我们在_name前面加上memory,而_favoriteNumber前面不加上memory呢,如果你试了,你会发现,它报了一个错误。

在这里插入图片描述

数据位置只适用于数组、结构体或映射类型,不能是uint256。

这是因为数组、结构体和映射类型在solidity中被认为是特殊的类型,而solidity可以自动知道uint256的位置。Solidity知道,对于这个函数,uint256将仅仅存在于内存中。然而,它不能确定string会是什么?

string实际上是有点复杂的,从背后原理上来说,一个string实际上是一个bytes数组,由于string是一个数组,我们需要把这个memory字节加进去。因为我们需要让solidity知道数组、结构体或映射类型的数据位置。所以,这就是为什么我们需要告诉它,它是在memory内存中的。

同时,我们也不能在此添加storage关键字,这个名字变量实际上并没有被存储到任何地方,你需要让它成为memory或calldata,这是它唯一接受的两种类型。

所以,总结一下,结构体、映射类型和数组在作为参数被添加到不同的函数时,需要给定一个memory或calldata关键字。

Mappings

现在这个列表很棒,但如果我们知道某人的名字,但不知道他最喜欢的数字,怎么办?

那么我们可以做的是,我们可以在整个数组中寻找那个人。例如在我们的合约中,我们可以从索引0开始寻找,一直往后查询,直到找到我们想要的那个人的信息。

不用说,这肯定很麻烦,假如人有很多,我们就得一直迭代到那个人所在的索引,这显然是非常低效的。有什么其他的方法来存储这些信息,使其更容易和更快的访问呢?

我们可以使用另一种数据结构,是一种叫做mapping映射的东西。

(类似C++的unordered_map或者Java的hashmap等等,但需要注意的是,Solidity的mapping有一些特殊的属性和限制,例如不能迭代访问所有键值对,只能通过键来访问对应的值。此外,Solidity 中的mapping可以存储任意类型的值,而在C++和Java中,需要指定键和值的具体类型)

你可以把映射想象成一种字典,它是一组键值对,每个key键返回与该键关联的某个value值。

我们创建一个映射变量的方式与创建所有其他变量的方式完全相同。所以这会是从string到uint256的mapping()类型,可见性关键字将是public,我们把它叫做nameToFavoriteNumber,现在我们有一个字典,每个名字都会映射到一个特定的数字。

所以让我们给addPerson()函数添加一些能力,所以我们要把我们的People添加到我们的数组中,还要把它们添加到我们的映射中,我们要做的是nameToFavoriteNumber[_name] = _favoriteNumber;。

代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract SimpleStorage {

    // This gets initialized to zero!
    // <- This means that this section is a comment!
    uint256 public favoriteNumber;

    mapping(string => uint256) public nameToFavoriteNumber;

    struct People {
        uint256 favoriteNumber;
        string name;
    }

    // uint256[] public favoriteNumbersList;
    People[] public people;

    function store(uint256 _favoriteNumber) public {
        favoriteNumber = _favoriteNumber;
        retrieve();
    }

    // view, pure
    function retrieve() public view returns(uint256) {
        return favoriteNumber;
    }

    // calldata, memory, storage
    function addPerson(string memory _name, uint _favoriteNumber) public {
        people.push(People(_favoriteNumber, _name));
        nameToFavoriteNumber[_name] = _favoriteNumber;
    }
}

因此,让我们继续编译它,来到我们的部署页面将它部署,点击,我们有一个名为nameToFavoriteNumber的新按钮。

在这里插入图片描述

当你创建一个映射时,会把所有东西都初始化为空值,现在这里每一个可能的字符串,都有一个对应的初始值favoriteNumber为0。

所以我们要手动添加值,就利用我们的addPerson()函数,添加一个人到我们的映射中,等待这个交易确实完成了,然后,让我们多添加几个人。

在这里插入图片描述

现在,如果我们查找某个人最喜欢数字,将会立即得到结果,当然我们也可以在people数组中找到它们。

在测试网上部署第一个合约

在以太坊测试网上部署合约-CSDN博客

引入其他合约

回到Remix,我们已经有了SimpleStorage.sol,现在我们有了SimpleStorage合约,它允许我们存储一个最喜爱的数字,也允许通过mapping映射和数组来存储最喜欢的数字。

假如我们想在这个方面做得更高级一些,可以使用一个合约来为我们部署其他合约,并进一步使用它的部署的这些合约进行交互。合约间的相互交互,是使用solidity和智能合约工作中必不可少的一部分。

合约可以无缝地互相交互的能力,这是我们说的可组合性。智能合约是可组合的,因为他们可以轻易地互相交互。当我们构建Defi应用时,这些特性显得尤其重要。复杂的金融产品间就可以非常轻易的互相交互,因为所有的合约代码在链上都可用。

我们会学习如何做到这些,保持SimpleStorage合约(上面代码块中的代码)原样不变,我们创建一个名为StorageFactory的新合约。

点击新建按钮,输入StorageFactory.sol。

在这里插入图片描述

接下来,我们开始编写StorageFactory合约的代码。

第一件要做的事,是SPDX-License-Identifier: MIT许可,接下来时Solidity版本,输入pragma solidity ^0.8.22,输入合约名称StorageFactory,保存,在编译选项卡中点击编译,合约就配置好了。

现在创建一个能够部署SimpleStorage合约的函数,创建名为createSimpleStorageContract的函数,设置为public,所以任何人都可以调用它,这个函数将部署一个SimpleStorage合约,并将其保存在一个全局变量中,但是做这些之前,StorageFactory合约如何知道SimpleStorage合约是什么样的呢?

代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract StorageFactory {

    function createSimpleStorageContract() public {
        // How does storage factory know what simple storage looks like?
    }
    
}

如果我们的StorageFactory合约要部署SimpleStorage合约,就需要知道SimpleStorage合约的代码。

一种方法是,复制SimpleStorage.sol中pragma solidity下方的所有代码,粘贴到StorageFactory.sol中,放置到pragma solidity下方。

代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract SimpleStorage {

    // This gets initialized to zero!
    // <- This means that this section is a comment!
    uint256 public favoriteNumber;

    mapping(string => uint256) public nameToFavoriteNumber;

    struct People {
        uint256 favoriteNumber;
        string name;
    }

    // uint256[] public favoriteNumbersList;
    People[] public people;

    function store(uint256 _favoriteNumber) public {
        favoriteNumber = _favoriteNumber;
        retrieve();
    }

    // view, pure
    function retrieve() public view returns(uint256) {
        return favoriteNumber;
    }

    // calldata, memory, storage
    function addPerson(string memory _name, uint _favoriteNumber) public {
        people.push(People(_favoriteNumber, _name));
        nameToFavoriteNumber[_name] = _favoriteNumber;
    }
}

contract StorageFactory {

    function createSimpleStorageContract() public {
        // How does storage factory know what simple storage looks like?
    }
    
}

编译并保存StorageFactory.sol,一切正常,现在,我们的StorageFactory.sol文件,实际上拥有2个合约,分别是Simple合约,和StorageFactory合约。

前往部署页面,往下滑,确保右边选中的是StorageFactory.sol,而不是SimpleStorage.sol,你会看到,可以选择要部署哪一个合约。

在这里插入图片描述

单个solidity文件,可以拥有多个不同的合约。

现在我们的StorageFactory中已经有了SimpleStorage,可以继续编写createSimpleStorageContract函数了。

我们创建一个全局变量,就像创建其他全局变量一样。类型是SimpleStorage,可见性为public,变量名为simpleStorage,在函数中,为变量simpleStorage赋值,new关键字会让Solidity知道,我们需要部署一个新的SimpleStorage合约。

contract StorageFactory {
    SimpleStorage public simpleStorage;

    function createSimpleStorageContract() public {
        simpleStorage = new SimpleStorage();
    }
}

前往编译页面进行编译。前往部署页面,确保环境选择的是Remix VM,下拉到合约选项,选择StorageFactory,记住,你需要让StorageFactory.sol处于选中状态,这里会显示StorageFactory。

点击部署,现在我们看到,StorageFactory合约有两个按钮。一个是调用createSimpleStorageContract函数。另一个是获取变量simpleStorage的值,我们现在点击它,返回的合约地址是0,是它默认的初始值,这表示当前还没有部署SimpleStorage合约。

在这里插入图片描述

上拉控制台,点击createSimpleStorage,可以看到我们发起了一次函数调用,StorageFactory.createSimpleStorageContract(),通过这样做,我们创建并部署了一个新的SimpleStorage合约。

在这里插入图片描述

来看看SimpleStorage合约的地址是什么,点击按钮,我们看到一个地址与其进行了关联,现在我们知道了,一个合约实际上可以部署另一个合约。

在这里插入图片描述

现在的问题是,StorageFactory上方的这一大块代码,显得有点冗余,尤其是我何已经有了一个叫SimpleStorage.sol的文件,假如我们有一个合约,它包含了很多其他的合约,复制这些合约的代码会是一项巨大的工作,所以,我们可以使用import来进行优化。

删除SimpleStorage的代码,输入import “./SimpleStorage.sol”,这样使用import,跟我们之前将代码复制过来会是同样的作用,这里可以指定其他文件的路径,也可以指定其他包或者GitHub的路径。

代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

import "./SimpleStorage.sol";

contract StorageFactory {
    SimpleStorage public simpleStorage;

    function createSimpleStorageContract() public {
        simpleStorage = new SimpleStorage();
    }

}

像这样引入合约,比起复制代码要好上很多,如果我们想更新SimpleStorage的代码,我们只需要在一个文件中进行改动,而不是在多处改动,另外,你也注意到了这个pragma solidity,如果我们的合约在两个独立的文件中,我们可以有不同的solidity版本号(前提是需要兼容的版本,不然编译器无法一起编译)。

我们的createSimpleStorageContract函数,每调用一次都会替换掉变量simpleStorage的值,我们来改进一下,将simpleStorage变量类型改为数组,变量名改为simpleStorageArray。

现在我们每创建一个新的SimpleStorage合约,不能像原来这样赋值了,而是要将其存储为SimpleStorage类型的内存变量,并将这个simpleStorage变量,添加到simpleStorageArray数组当中,就像之前一样,使用simpleStorageArray.push(simpleStorage)。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

import "./SimpleStorage.sol";

contract StorageFactory {
    SimpleStorage[] public simpleStorageArray;

    function createSimpleStorageContract() public {
        SimpleStorage simpleStorage = new SimpleStorage();
        simpleStorageArray.push(simpleStorage);
    }
}

编译,部署StorageFactory,在这里,我们看到查看simpleStorageArray的按钮,我们创建一个SimpleStorage,然后在索引0的位置可以看到合约地址,现在索引1的位置,还没有值,如果我们再创建一个SimpleStorage,索引1的位置现在能看到合约地址了。

在这里插入图片描述

与其他合约交互

现在我们可以追踪所有的SimpleStorage的部署了,但我们要如何跟它们交互呢,比如我们想要在StorageFactory中调用SimpleStorage的store函数。

你可以把StorageFactory当成是所有SimpleStorage的管理者,让我们来创建一个新的函数来完成这件事情,创建名为sfStore的函数,意思是StorageFactoryStore,参数是uint256类型的_simpleStoragelndex和uint256类型的simpleStorageNumber,它也是public的函数。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

import "./SimpleStorage.sol";

contract StorageFactory {
    SimpleStorage[] public simpleStorageArray;

    function createSimpleStorageContract() public {
        SimpleStorage simpleStorage = new SimpleStorage();
        simpleStorageArray.push(simpleStorage);
    }

    function sfStore(uint256 _simpleStorageIndex, uint256 _simpleStorageNumber) public {
        // Address
        // ABI - Application Binary Interface
    }
}

要和其他的合约进行交互,你需要2样东西,一是需要合约地址,二是需要合约ABI。ABI指的是应用程序二进制接口,ABI会告诉我们的代码如何来跟合约进行交互,我们来深入了解一下ABl。

前往编译洗项卡,点击编译,合约正在编译中,下拉页面,你会看到编译详情。这里会看到不同合约的大量信息,你可以看到合约名称是SimpleStorage,METADATA里包含编译器,语言,输出,配置等信息,然后是字节码,里面包含opcodes。

在这里插入图片描述

你还可以看到ABI,它显示了所有不同的输入和输出,即这个合约能做的所有事情,比如,在我们的SimpleStorage,ABI中索引为0的位置,是函数addPerson。索引1,我们可以看到是favoriteNumber。索引2,nameToFavoriteNumber。索引3,people。索引4,retrieve。索引5,store。

在这里插入图片描述

它告诉了我们可以和合约进行交互的所有的这些不同的方式。告诉我们可以调用的不同的函数,我们知道地址在哪,我们把它存储在了simpleStorageArray中,我们也获得了ABI。

因为我们导入了SimpleStorage.sol,当你编译SimpleStorage.sol时,不论你何时编译它,正如在编译详情所见,它都与ABI预先打包好了,我们可以自动获得ABI,只需像这样导入它。将来我们会看到可以通过其他方式来轻松获得ABI。

要调用我们合约中的store函数,首先要获得合约对象,我们如何做到这一点?

定义一个变量,名字是simpleStorage,类型是SimpleStorage。它会是一个SimpleStorage类型的对象,不像上次我们是用new来创建对象,这回我们把地址放在括号里,地址可以从我们的数组获得。

也就是说,调用这个函数,我们会传递一个数组的索引,这个地址有一个SimpleStorage合约,地址是simpleStorageArray[_simpleStoragelndex]。括号这个语法,可以让你访问数组中的不同元素,如果要获得列表中索引为0的元素,simpleStoragelndex参数应传递0,把它传递进来,它就会给返回 SimpleStorage的合约地址。

因为这是一个SimpleStorage合约的数组,我们可以用索引来访问SimpleStorage合约,也就是使用simpleStorageArray[_simpleStoragelndex]。现在我们把SimpleStorage合约对象,赋值给了simpleStorage变量,我们可以在数组中找到地址。

同时这里也自动获得了ABI,如果这是对象地址类型的数组,就需要像这样对地址进行包装,这个问题后面再谈。

    function sfStore(uint256 _simpleStorageIndex, uint256 _simpleStorageNumber) public {
        // Address
        // ABI - Application Binary Interface
        SimpleStorage simpleStorage = simpleStorageArray[_simpleStorageIndex];
        simpleStorage.store(_simpleStorageNumber);
    }

现在我们已经有simpleStorage了现在我们可以通过simpleStorage调用store函数,现在通过simpleStorage.store()存储_simpleStorageNumber,这很完美。如果我们现在部署它,我们还不能读取store函数,让我们来创建另一个函数,可以在StorageEactory里读取SimpleStorage合约中的数据。

创建名为sfGet的函数,意思是StorageFactoryGet,参数是uint256类型的_simpleStoragelndex,它是public view的函数,因为只从SimpleStorage合约中读取数据。它会返回uint256类型的数据。这里我们将SimpleStorage simpleStorage赋值为,我们使用跟上面同样的语法来获取合约对象,simpleStorageArray[_simpleStoragelndex],使用simpleStorage.retrieve(),返回我们在这里存入的数字。

    function sfGet(uint256 _simpleStorageIndex) public view returns(uint256) {
        SimpleStorage simpleStorage = simpleStorageArray[_simpleStorageIndex];
        return simpleStorage.retrieve();
    }

代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

import "./SimpleStorage.sol";

contract StorageFactory {
    SimpleStorage[] public simpleStorageArray;

    function createSimpleStorageContract() public {
        SimpleStorage simpleStorage = new SimpleStorage();
        simpleStorageArray.push(simpleStorage);
    }

    function sfStore(uint256 _simpleStorageIndex, uint256 _simpleStorageNumber) public {
        // Address
        // ABI - Application Binary Interface
        SimpleStorage simpleStorage = simpleStorageArray[_simpleStorageIndex];
        simpleStorage.store(_simpleStorageNumber);
    }

    function sfGet(uint256 _simpleStorageIndex) public view returns(uint256) {
        SimpleStorage simpleStorage = simpleStorageArray[_simpleStorageIndex];
        return simpleStorage.retrieve();
    }
}

然后我们编译部署一下。

下拉页面,现在如果我们在sfGet函数传入0,我们什么也得不到。simpleStorage传入参数0,同样什么都没有。来创建一个SimpleStorage合约,现在我们能从索引0上获取合约地址了。

在这里插入图片描述

为这个合约存入一个数字,这个合约的索引是0,索引参数传入0,存入数字7。调用sfStore函数。如果一切正常,数字7将会存入这个合约。调用sfGet函数传入0,确实返回了7。如果传入1,什么也没发生。事实上这里有一个revert的错误。

在这里插入图片描述

我们再创建一个SimpleStorage合约,调用sfGet函数传入1,返回了默认值0。调用sfStore函数,索引传入1,存入数字16,调用sfGet函数传入1,返回了16。

在这里插入图片描述

StorageFactory合约允许我们创建SimpleStorage合约,并把它们存入simpleStorageArray数组,然后我们在下面的函数可以使用这个数组,在StorageFactory合约里我们可以存入数据,也可以读取我们所创建的所有的SimpleStorage合约中的数据,这非常强大。

我们可以进一步的优化这两个函数,我们可以在这里直接调用retrieve,我们在通过这个数组返回了一个SimpleStorage对象,我们可以删除这部分代码,这里直接调用retrieve函数,删掉下面这行,这里加上return,保存编译,显示了绿色对勾,只要这里是 SimpleStorage变量,就可以调用retrieve。

    function sfGet(uint256 _simpleStorageIndex) public view returns(uint256) {
        return simpleStorageArray[_simpleStorageIndex].retrieve();
    }

上面这里我们也同样优化一下,删除这部分,直接调用store函数,传入_simpleStoragelndex,保存编译,它和之前一样可以正常工作。

    function sfStore(uint256 _simpleStorageIndex, uint256 _simpleStorageNumber) public {
        // Address
        // ABI - Application Binary Interface
        simpleStorageArray[_simpleStorageIndex].store(_simpleStorageNumber);
    }

代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

import "./SimpleStorage.sol";

contract StorageFactory {
    SimpleStorage[] public simpleStorageArray;

    function createSimpleStorageContract() public {
        SimpleStorage simpleStorage = new SimpleStorage();
        simpleStorageArray.push(simpleStorage);
    }

    function sfStore(uint256 _simpleStorageIndex, uint256 _simpleStorageNumber) public {
        // Address
        // ABI - Application Binary Interface
        simpleStorageArray[_simpleStorageIndex].store(_simpleStorageNumber);
    }

    function sfGet(uint256 _simpleStorageIndex) public view returns(uint256) {
        return simpleStorageArray[_simpleStorageIndex].retrieve();
    }
}

现在我们有了可以存储变量的SimpleStorage合约,有了StorageFactory合约,可以管理这些SimpleStorage合约,可以部署它们并和它们进行交互。

继承和重载

我们真的很喜欢SimpleStorage合约,但是它不能完成我们希望做到的所有事情。比方说,我们存储的时候,不是只存储最喜爱的数字,而是存储最喜欢的数字再加上5。有时候你会需要一个这样的合约,所有人的最喜爱的数字都比他们认为的数要大5,当你真的很喜欢这个合约里的其他函数时,来创建一个新的合约,命名为ExtraStorage,输入ExtraStorage.sol。

跟平常一样先配置这个合约,设置SPDX-License-ldentifier: MIT,pragma solidity设置为^0.8.22,合约名为ExtraStorage,保存编译,看到了这个绿色对勾。

在这里插入图片描述

我们可以做什么?第一件事是可以把这里的代码全部复制过来,然后视情况修改它,这元得非常冗余,工作量也很大。有什么其他方法可以让ExtraStorage合约像SimpleStorage合约一样吗,这就是我们要说的继承。

你可以理解为ExtraStorage合约是SimpleStorage合约的子合约,使ExtraStorage继承SimpleStorage的全部函数,只需通过2行代码。

首先,为了让ExtraStorage了解SimpleStorage,我们需要导SimpleStorage,输入import “./SimpleStorage.sol”,然后,使用is关键字指定继承关系。

代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

import "./SimpleStorage.sol";

contract ExtraStorage is SimpleStorage {
    
}

保存编译,现在ExtraStorage和SimpleStorage一样了,它继承了SimpleStorage合约的全部功能。

来验证一下,我们编译部署它。

在这里插入图片描述

在这里可以看到,ExtraStorage拥有SimpleStorage的所有函数。如果你想让一个合约能继承另一个合约的所有功能,只需把它导入,并使用is指定继承关系。

现在我们给ExtraStorage合约加一些额外的函数,同时它也包含SimpleStorage合约的所有功能。

现在,ExtraStorage已经继承了SimpleStorage,然后,我们不太喜欢SimpleStorage合约中的一个函数,回到SimpleStorage合约,store函数把参数中传递进来的_favoriteNumber赋值给了全局变量。

    function store(uint256 _favoriteNumber) public {
        favoriteNumber = _favoriteNumber;
        retrieve();
    }

在ExtraStorage合约,我们希望store函数做一些不同的事情,我们希望在传递过来的数字上加上5,要如何做到呢?我们会使用到重载,要用到2个关键字,virtual和override,现在,我们来实现store,看看会发生什么。

function store(uint256_favoriteNumber),它是一个public函数,不仅是存储_favoriteNumber,而是在这个变量上加上5。

contract ExtraStorage is SimpleStorage {
    // + 5
    // override
    // virtual override
    function store(uint256 _favoriteNumber) public {
        favoriteNumber = _favoriteNumber + 5;
    }
}

如果我们现在编译,会看到两个不同的错误,第一个是提示缺少override修饰符,如果父合约,在这里就是SimpleStorage合约,拥有同样的函数,我们要告诉Solidity需要重写store函数,而不是使用这个函数。另一个错误是提示我们正常尝试重写非virtual的函数,是忘了添加virtual修饰符了吗?

在这里插入图片描述

为了使得这个函数可以被重写,需要加上virtual关键字,现在它可以被重写了,然后给store函数添加override关键字,现在可以正常编译了。

在这里插入图片描述

在这里插入图片描述

代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract SimpleStorage {

    // This gets initialized to zero!
    // <- This means that this section is a comment!
    uint256 public favoriteNumber;

    mapping(string => uint256) public nameToFavoriteNumber;

    struct People {
        uint256 favoriteNumber;
        string name;
    }

    // uint256[] public favoriteNumbersList;
    People[] public people;

    function store(uint256 _favoriteNumber) public virtual  {
        favoriteNumber = _favoriteNumber;
        retrieve();
    }

    // view, pure
    function retrieve() public view returns(uint256) {
        return favoriteNumber;
    }

    // calldata, memory, storage
    function addPerson(string memory _name, uint _favoriteNumber) public {
        people.push(People(_favoriteNumber, _name));
        nameToFavoriteNumber[_name] = _favoriteNumber;
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

import "./SimpleStorage.sol";

contract ExtraStorage is SimpleStorage {
    // + 5
    // override
    // virtual override
    function store(uint256 _favoriteNumber) public override {
        favoriteNumber = _favoriteNumber + 5;
    }
}

让我们部署它,先删除旧的合约,Remix VM环境,很好,选择ExtraStorage,点击部署,这里会看到部署好的ExtraStorage合约。

现在调用retrieve函数,返回了0,在这之前,使用store函数存储数字,然后现在,我们存入5,再加上5,实际存储的应该是10,调用store函数,看起来这个交易通过了,现在调用retrieve函数,确实返回了10,这就是继承和重写函数。

在这里插入图片描述

通过函数发送 ETH & 返回

接下来,我们将创建一个合约,用来学习后面的Solidity基础内容。

FundMe.sol是一个智能合约,它可以人们发起一个众筹。

所以人们可以向该智能合约发送ETH、Polygon、 Ava、Fantom或者其他区块链原生通证,然后这个智能合约拥有者可以提取这些通证来做他们想做的事。

此时,在remix中,这里有一些合约,SimpleStorage.sol、StorageFactory.sol和 ExtraStorage.sol,我们将创建一个新的FundMe.sol合约。

让我们开始创建FundMe合约吧,像之前说到的,我们想要这个合约能从用户处获得资金,然后能够提取资金,同时设置一个以eth(以太币)计价的最小资助额,这就是我们要让合约做到的事情。

首先,让我们设置 SPDX-License-ldentifier: MIT,然后设置solidity版本为^0.8.22,然后我们合约起名FundMe,然后,点击Compile FundMe.sol,看看是否编译正常。

代码:

// Get funds from users
// Withdraw funds
// Set a minimum funding value in ETH

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract FundMe {
    
}

编译正常,好的,那我们继续。

在实现所有函数逻辑之前,让我们先把要实现的函数写下来,我们需要Fund函数,人们可以通过它来发送资金,我们还需要一个Withdraw函数,这样合约的owner(拥有者)可以提取不同的funder 发送的资金。

contract FundMe {
    
    // function fund(){}
    
    // function withdraw(){}
}

这些差不多就是我们要实现的函数,这就是我们希望这个合约能实现的两个主要函数,可能还会实现更多函数来帮助这两个函数更加完善。

但是让我们先从Fund开始,先把 Withdraw注释掉,我们希望任何人都可以调用Fund函数,所以我们把它设为public,正如之前提到的,我们希望能够设置一个以ETH计算的最小金额,这里有很多事情要考虑,第一件就是如何向这个合约转ETH。

contract FundMe {
    
    function fund() public {
        // Want to be able to set a minimum fund amount in ETH
        // 1. How do we send ETH to this contract?
    }

    // function withdraw(){}
}

每当我们在任何一个兼容EVM的区块链上创建一笔交易时,这个value的值,代表我们将通过这笔交易发送多少ETH。

在这里插入图片描述

例如,当我们在不同账户间转移ETH时,实际上就是在value中填充不同的ETH数量。

事实上,以太坊上发送的每笔交易都包含一些必要字段,这些字段包括:

  1. Nonce:这是发送地址的交易计数器。它确保交易按顺序执行,并防止重播攻击。
  2. Gas Price:这是发送者愿意支付的 gas 单价,以太坊网络会根据 gas price 和 gas limit 来计算手续费。
  3. Gas Limit:这是交易执行所需的最大 gas 数量。它决定了交易的复杂程度和成本。
  4. To:这是接收以太币或者调用智能合约的目标地址。
  5. Value:这是发送的以太币数量。
  6. Data:这是可选字段,用于向智能合约发送数据。
  7. v、r、s:这些字段用于交易的签名验证,确保交易的安全性和完整性。

在转账的时候,我们可以填充其中一些字段,例如,gas limit中填充的21,000,data是空的,然后to是我们想要将交易发送到的地址,在函数调用的交易中,仍然可以以这种方式填写to,我们可以调用一个函数,并同时进行转账。

在remix中,有一个下拉菜单中,有Wei,Gwei,Finney,Ether,我们这里先忽略Finney。

每当我们在任何一个兼容EVM的区块链上创建一笔交易时,这个value的值,代表我们将通过这笔交易发送多少ETH。

在这里插入图片描述

我们可以来到Wei, Gwei和Ether的计算器Ethereum Unit Converter | Ether to Gwei, Wei, Finney, Szabo, Shannon etc. (eth-converter.com)

在这里插入图片描述

—个Eth值这么多Gwei,值这么多Wei。

为了使函数可以被ETH或任何其它通证支付,我们首先将函数设为payable,payable这个关键字让Fund函数变红,而不是普通的橙色。

contract FundMe {
    
    function fund() public payable {
        // Want to be able to set a minimum fund amount in ETH
        // 1. How do we send ETH to this contract?
    }

    // function withdraw(){}
}

就像我们的钱包可以持有资金,合约地址也可以持有资金,每次部署合约时,可以获得一个合约地址,它与钱包地址几乎一致,所以钱包和合约都可以持有像ETH这样的原生区块链通证,稍后部署我们的合约后,你会看到这个地址的ETH余额。

现在我们已经让这个函数成为payable了,然后可以在函数中用全局关键字msg.value,来知道某人转账的金额。

现在假设我们想将msg.value赋予一个某个数值的以太币,假设我们希望人们在所有交易中至少转一个以太币,或者换个说法,他们至少要转一个以太币我们要如何实现呢,好吧,我们通过require实现,可以require msg.value > 1e18。

代码:

// Get funds from users
// Withdraw funds
// Set a minimum funding value in ETH

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract FundMe {
    
    function fund() public payable {
        // Want to be able to set a minimum fund amount in ETH
        // 1. How do we send ETH to this contract?
        require(msg.value > 1e18, "Didn't send enough!"); // 1e18 == 1 * 10 ** 18 == 1000000000000000000
    }

    // function withdraw(){}
}

这里需要解释一下,1e18等于1*10**18,也就是1,000,000,000,000,000,000,这么多Wei等于1个ETH。

如果要求msg.value 至少发送1个ETH(或者Polygon,ava等等),可以设置require (msg.value) > 1,这个require关键字会检查msg.value是否大于1,如果不是,它将revert这个交易,revert会同时显示错误信息“didn’ t send enough”。

现在在Remix VM部署这个合约,点击Deploy,部署FundMe合约,可以看到fund函数的按钮变红了,如果我们现在调用fund函数,可以看到控制台中,有一个错误。

在这里插入图片描述

错误原因就是之前设置的“didn’t send enough”,所以需要做的是在Fund交易中转至少一个ETH,满足require中的设置条件。

回到上面的value字段,修改数值为2,所以这里应该是大于1个ETH或者对应多的Wei或者Gwei,发送2个ETH,向下滚动,现在我们可以点击 fund。

在这里插入图片描述

可以看到通过了require的检查,如果require第一部分是false,那么将revert并且显示这个错误。

revert是什么?revert可能有一点不好理解,revert是将之前的操作回滚,并将剩余的gas费返回,那么实际是什么样的呢?

比如说,创建uint256数据类型的public变量number,在fund函数中,给number赋值5。

contract FundMe {
    uint256 public number;
    
    function fund() public payable {
        // Want to be able to set a minimum fund amount in ETH
        // 1. How do we send ETH to this contract?
        number = 5;
        require(msg.value > 1e18, "Didn't send enough!"); // 1e18 == 1 * 10 ** 18 == 1000000000000000000
        // a ton of computation here

        // What is reverting?
        // Undo any action before, and send remaining gas back
    }

    // function withdraw(){}
}

先删掉旧的合约,再部署这个新合约,number现在为0。

在这里插入图片描述

但如果我们调用fund函数,number就会被赋值为5,然而,如果我们调用fund函数,require条件没有满足,这个交易将会被revert,将numbere设置为5的操作会被撤销。

我们查看一下日志,我们保持value为0,所以fund函数还是会revert,调用fund函数,可以看到交易失败,因为require条件没有满足,所以number仍为0。

在这里插入图片描述

还有个问题,我们是否真的花费了gas,我们花费gas来使number变为5,剩余的gas应该被这个require返回,例如,如果我们在require后面有需要大量计算资源的操作,调用fund函数时会花费大量的gas,当这个交易在这里被revert后,所有后续的gas将被返回给原用户。

如果这里有一些不好理解,别担心,我们在后续还会学习它。现在你所需要知道的是,当有一个require时,如果条件没有满足,交易将被取消,任何之前的操作都将被撤销,并且会发送一条错误信息。

好的,我们先在全局作用域中删除number,这里其实还有一种方法可以让交易revert,将在这个合约的后面学习中讨论。

数组和结构体(续)

接着上面的内容,然后我们将代码稍微修改一下(将ETH限制发送的数量作为全局变量)。

代码:

// Get funds from users
// Withdraw funds
// Set a minimum funding value in ETH

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract FundMe {

    uint256 public minimumETH = 0.01 * 1e18; // 1 * 10 ** 18
    
    function fund() public payable {
        // Want to be able to set a minimum fund amount in currency
        // 1. How do we send ETH to this contract?
        require(msg.value >= minimumETH, "Didn't send enough!");
    }

    // function withdraw(){}
}

接下来,我们想要在funding合约中做什么呢?

当人们给这个合约发送资金的时候,我们想要记录下来这些人,所以我们创建一个数据类型来记录他们。创建一个名为funders的地址数组,记录所有发送资金的funder,这是一个地址数组,或者地址列表,我们把它定位为public。

任何实际时间,有人给我们发送资金,通过require以后,我们就会把地址加入funder列表,写funder.push(msg.sender),就如同msg.value。msg.sender也是一个全局关键字,msg.value代表有多少ETH或者其他原生通证被发送了,msg.sender是调用这个函数的人的地址,如果现在使用的是Sepolia,msg.sender就是这个调用函数的地址,因为我们的地址发送了ETH,我们将会把自己的地址加入到funder数组中,通过这个方法,我们可以追踪所有给我们合约捐助的人。

然后我们可能想要创建一个address到uint256的mapping,记录每个地址发送资金的数量,所以我们的mapping是address映射到uint256,public addressToAmountFunded。当有人给合约发送资金的时候,就通过addressToAmountFunded[msg.sender] = msg.value存储。

代码:

// Get funds from users
// Withdraw funds
// Set a minimum funding value in ETH

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract FundMe {

    uint256 public minimumETH = 0.01 * 1e18; // 1 * 10 ** 18

    address[] public funders;
    mapping(address => uint256) public addressToAmountFunded;
    
    function fund() public payable {
        // Want to be able to set a minimum fund amount in currency
        // 1. How do we send ETH to this contract?
        require(msg.value >= minimumETH, "Didn't send enough!");
        funders.push(msg.sender);
        addressToAmountFunded[msg.sender] = msg.value;
    }

    // function withdraw(){}
}

现在有了人们可以发送资金的函数,也在合约中记录了发送资金的人,这很棒!

For loop

很棒,到目前为止,我们的fund方法已经完成了,现在任何人都可以来直接为这个合约提供资金,将以太或任何原生区块链货币转给这个合约。

现在,接下来在如果所有的funder都已经开始资助我们,然后我们要做什么呢,我们会希望项目方能够从合约中提取资金,这样他们就可以直接使用这些资金为这个项目去购买东西,所以让我们接下来来创建一个资金提取函数。

因此我们将创建一个withdraw函数,并将其设置为public,因为我们将从该合约中提取所有资金,所以还需要将重置funders 数组以及地址对应的资助金额,因为我们将提取所有资金,因此这些资金金额应该要重置为零,所以让我们接着开始遍历funders数组并更新我们的mapping object,使得这些funder的余额现在都为零,因为我们马上从他们那里取出所有的钱,为了做到这一点,我们将使用一种叫做for循环,所以什么是for循环?

for循环是一种将某种类型的索引对象进行循环的方式或者将某些范围内的数字进行循环的方法,或者只是将一项任务重复执行某个次数,例如,我们有一个数组或一个列表,在该列表上我们有一二三四,如果我们想获取此数组或此列表中的所有元素(即一二三四),我们如何很好地获取此列表中的所有元素?我们可以将使用for循环遍历这些对象中的每一个。

因此,第零个索引将是1,第一个索引将是2,而在第二个索引上的将是3,在最后一个索引上的是4,所以我们会将索引0到3进行循环来获取所有这些元素,或者另一个例子是如果这是abcd,那么a是索引零,b是第一个索引,c是第二个,d是第三个,我们将遍历零到三的索引来得到这些元素。接下来我们将做同样的事情,但是会使用funders数组,我们具体如何做呢?

我们首先从for这个关键字开始,for这个关键字表示我们即将开始一个循环,而在这些括号内,我们会定义要如何循环遍历它。

/*和*/有点像注释的括号,这两者之间的任何内容都是注释。

所以在for循环中,首先我们要给它一个起始索引,然后我们要给它一个结束索引,然后我们规定每次增加的值。例如,也许我们想从0开始,然后我们想到达10,以及我们想每次增加1,所以我们会像这样012345678910走上去,或者我们从0开始我们想在10结束,我们每次增加2,所以我们会像这样0246810这样加上去,或者我们想从0增加到5,我们想从2增加到5,每次增加1,我们会像这样2345遍历下去等等。

    function withdraw() public {
        /* starting index, ending index, step amount */
        for (uint256 funderIndex = 0; funderIndex < funders.length; funderIndex++) {
            // code
        }
    }

这就是这里for 循环的内容,所以对于我们的起始索引来说,让我把它放在这上面,这样你就可以直接引用它,所以我们的起始索引将是uint 256变量,我们将其称为funder索引,我们将从funder索引等于零开始,所以我们从零开始,我们将在funder数组的长度最大值处结束。

因为我们想要遍历所有funder,所以我们要限制funder资助者小于funders.length,所以我们的结束索引将是,当资助者funder index不再小于funders.length的时候。

然后最后我们要funder index = funder index +1,这意味着每次循环内的代码完成时,我们都会将funder index加一。

这就是我们如何从0到1到2到3到4到5等等,另一种方式你可以输入funder index = funder index +1,或者你可以只做funder index++,这个++语法意味着funder index等于它本身加一。

所以让我们开始循环遍历我们的funders数组,以访问第零个元素或第一个元素,我们将说资助者索引里的资助者,所以我们说我们想要访问funders的第零个元素,这将返回一个地址。

我们将认为funder的地址等于资助者索引中的funder,所以现在我们有了这个funder地址,我们想用它来重置我们的mapping,所以addressToAmountFunded这个mapping在key是funder所对应的 value赋值为0。

    function withdraw() public {
        /* starting index, ending index, step amount */
        for (uint256 funderIndex = 0; funderIndex < funders.length; funderIndex++) {
            // code
            address funder = funders[funderIndex];
            addressToAmountFunded[funder] = 0;
        }
    }

因为在fund函数中,只有在合同被提供资金时,我们才会更新金额,当我们从合约中提取资金时,我们将把它重置为零,我们将得到第零个funder,我们将从索引为零处获取该funder的信息,并将该funder在addressToAmountFunded对应的value重置为零。

然后用这个for循环会增加1它将从零移动到一,然后它会检查funder index是否小于数组长度。假设资助者有10人,如果资助者有10人,这个funder index就仍然是小于其数组长度的。

所以现在funder index现在是1,其地址现在就是funder index为1的信息,而不是index为0的信息,我们将获取该地址,并将该地址的资助金额重置为零,然后我们将继续从2到3到4,一直到这个数字等于我们的funder数组的长度,这就是我们如何用循环的方式去遍历对象。

所以要是你说这个中间这部分就是结束的index,这也并不完全正确,因为我们会通过检查布尔值来看其是否仍然为真。

代码:

// Get funds from users
// Withdraw funds
// Set a minimum funding value in ETH

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract FundMe {

    uint256 public minimumETH = 0.01 * 1e18; // 1 * 10 ** 18

    address[] public funders;
    mapping(address => uint256) public addressToAmountFunded;
    
    function fund() public payable {
        // Want to be able to set a minimum fund amount in currency
        // 1. How do we send ETH to this contract?
        require(msg.value >= minimumETH, "Didn't send enough!");
        funders.push(msg.sender);
        addressToAmountFunded[msg.sender] = msg.value;
    }

    function withdraw() public {
        /* starting index, ending index, step amount */
        for (uint256 funderIndex = 0; funderIndex < funders.length; funderIndex++) {
            // code
            address funder = funders[funderIndex];
            addressToAmountFunded[funder] = 0;
        }
        // resret the array
        // actually withdraw the funds
    }
}

所以我们已经重置了mapping的余额。

但是我们还有两件事要完成,我们仍然需要重置数组以使资助者成为空数组,然后我们还需要真正提取资金,因为当我们给合约发送资金,我们在fund函数中发送了message.value,然而,我们实际上并没有提取资金。

所以要去重置数组的话,我们可以遍历一遍它并从这个地址数组中删除里面的对象,或者我们可以整个重置这个变量。

重置数组

这一次我们不再通过循环遍历的方式删除所有数组内的对象,我们可以这样写funders = new address array,这样做就会完全重置整个funders 数组。

    function withdraw() public {
        /* starting index, ending index, step amount */
        for (uint256 funderIndex = 0; funderIndex < funders.length; funderIndex++) {
            // code
            address funder = funders[funderIndex];
            addressToAmountFunded[funder] = 0;
        }
        // resret the array
        funders = new address[](0);
        // actually withdraw the funds
    }

它现在是一个全新的address 类型数组了,并且里面没有任何(有0个)对象。

funders = new address[](1);

如果我们把这里改成1,这个数组就会有1个初始元素在里面,改成2就是有两个,改成3就是有三个,以此类推。不过我们在这里想要的就只是一个全新的,空白的数组,如果这部分不太理解,不要纠结。

很好,我们已经重置了这个数组。但我们现在究竟该如何实际地从这个合约中提取资金呢?或者说我们该如何把资金发送给合约的调用者?

transfer,send,和call

现在,要想发送以太币或者其他区块链原生货币的话,有三种不同的方式可供使用,这三种方式我们都会看看,并且会说一下它们之间有什么区别。

这三种方式分别是transfer,send,和call。

让我们从transfer开始,因为transfer是最简单的,使用起来也是最直观的,所以如果我们想要转移资金,给调用这个withdraw函数的人,我们该这样写,msg.sender.transfer。

然后我们要得到这个合约的余额,写下address(this).balance,关键字this指的就是这整个合约本身。这样我们就可以获得这个地址的区块链原生货币或者说以太币余额了。

只用做这些就够了,我们唯一要再做的一件事就是需要做一下类型转换,我们要把msq.sender 从address类型转换到payable address类型,所以说msg.sender是一个address类型,而payable(msg.sender)是payable address类型。

    function withdraw() public {
        /* starting index, ending index, step amount */
        for (uint256 funderIndex = 0; funderIndex < funders.length; funderIndex++) {
            // code
            address funder = funders[funderIndex];
            addressToAmountFunded[funder] = 0;
        }
        // resret the array
        funders = new address[](0);
        // actually withdraw the funds

        // transfer
        // send
        // call

        // msg.sender = address
        // payable(msg.sender) = payable address
        payable(msg.sender).transfer(address(this).balance);
    }

在solidity 中要想发送区块链原生货币比如以太币,你必须使用payable address类型才能做到,所以我们把它放到这个payable类型转换器里。

这就是我们发送以太币的第一种方法,这种方法也可以用于不同合约之间互相发送代币,我们只需要把想要发送到的目标地址放到payable关键字里,然后写下.transfer,并且在这里告诉它我们到底要转移多少资金,但是transfer 还存在一些自身的问题。

现在我们来到这个solidity-by-example网址的sending-ether页面:

Sending Ether (transfer, send, call) | Solidity by Example | 0.8.20 (solidity-by-example.org)

另外,这个网址是一个非常好的参考资料,如果你有疑惑的话。

在这里插入图片描述

我们刚刚看过的方法就是这个transfer方法,在之前的文章中我们已经知道了,如果我要把以太币,从一个地址发送到另一个地址,这笔交易大约消耗2100gas,我们的transfer函数的上限是2300gas,如果超出这个上限,它就会报错。

下一个我们要使用的方法是send,它的消耗上限同样也是2300gas,而如果它运行失败了,则会返回一个布尔值。

所以对于transfer来说,如果这一行运行失败,它会直接报错并且回滚交易,但使用send,就不会直接报错,而是会返回一个表示运行是否成功的布尔值。

要想使用send,我们写下payable(msg.sender).send(address(this).balance),但是我们不能就这么结束这一语句,如果这一行运行失败了,合约不会回滚交易,我们就只是单纯没拿到钱而已。

所以我们这里还得写下bool sendSuccess等于整个这一部分,然后写下require sendSuccess,如果这里发送失败了就会报一个"Send failed”错误。

这样做的话,如果这里运行失败,通过require语句我们还是可以回滚交易,如果transfer运行失败它会自动回滚交易,而send要想回滚交易,我们就必须在这里添加require语句。

    function withdraw() public {
        /* starting index, ending index, step amount */
        for (uint256 funderIndex = 0; funderIndex < funders.length; funderIndex++) {
            // code
            address funder = funders[funderIndex];
            addressToAmountFunded[funder] = 0;
        }
        // resret the array
        funders = new address[](0);
        // actually withdraw the funds

        // transfer
        payable(msg.sender).transfer(address(this).balance);
        // send
        bool sendSuccess = payable(msg.sender).send(address(this).balance);
        require(sendSuccess, "Send failed");
        // call
    }

很好,那么第三种发送以太币或者其他原生货币的方式是什么呢?

就是这个call命令,call将会是我们接触的第一个在solidity 中实际使用的较为底层的命令,我们可以用它来调用几乎所有solidity的函数,甚至不需要依赖ABI,我们会在以后更深入的学习如何使用这一高级功能,但是现在,我们只学习一下如何使用它来发送ETH或者其他区块链原生货币。

call和send其实也很相似,我们写下payable(msg.sender).call,这个地方就是我们填写任何函数信息,或者说,任何我们想调用的某个合约的函数信息,我们现在并不打算调用哪个函数,所以我们在这里留白就行了,在这里填上两个引号以表明我们在此处留白。

payable(msg.sender).call("");

现在我们反而像是要发送一笔交易一样来使用它了,正如我们在部署界面看到的,这里总是有一个用来提供 msg.value的位置。

在这里插入图片描述

所以我们要把这个call函数当做一个普通交易来使用,并且要为它添加msg.value这样的东西,所以在这里,我们要添加一组花括号,这里我们写value: address(this).balance。

payable(msg.sender).call{value: address(this).balance}("");

这个call函数实际上有两个返回值,当一个函数有两个返回值时,我们可以把它们放到左边的括号里来表示,这两个返回值的其中之一是布尔值,我们把它称为callSuccess,另外一个则是bytes对象,称之为dataReturned。

(bool callSuccess, bytes memory dataReturned) =  payable(msg.sender).call{value: address(this).balance}("");

因为call允许我们调用不同的函数,如果那个函数本身就返回一些数据或者说有返回值,我们就得把这个返回值给保存下来,它还返回callSuccess,当我们的函数被call成功调用时,这个值就是true,反之,就是false,并且因为 bytes对象是数组,所以dataReturned还需要放到memory中。

现在对于我们这里的代码来说,我们实际上没有调用某个函数,所以我们其实不用关心dataReturned,我们只需要把这里删掉留下逗号,以此来告诉solidity,是的,我知道这个函数有两个返回值,但我们只关心其中的一个。

然后就和上面send一样,我们要补上require,callSuccess,Call failed,意思就是我们要求callSuccess必须为true,否则,我们将回滚交易并抛出一个"Call failed"错误。

    function withdraw() public {
        /* starting index, ending index, step amount */
        for (uint256 funderIndex = 0; funderIndex < funders.length; funderIndex++) {
            // code
            address funder = funders[funderIndex];
            addressToAmountFunded[funder] = 0;
        }
        // resret the array
        funders = new address[](0);
        // actually withdraw the funds

        // transfer
        payable(msg.sender).transfer(address(this).balance);
        // send
        bool sendSuccess = payable(msg.sender).send(address(this).balance);
        require(sendSuccess, "Send failed");
        // call
        (bool callSuccess, ) =  payable(msg.sender).call{value: address(this).balance}("");
        require(callSuccess, "Call failed");
    }

如果你现在还不太能搞懂它们三个之间的区别,不要过于纠结它们以至于拖累你的学习进度,等之后再回过头来重新审视这里,可以是在你学习了更多关于底层函数的工作机制后,也可以是学习了更多gas工作机制后。

solidity-by-example非常好的解释了这三者之间的区别,transfer最大允许2300 gas并且运行失败时会直接报错,send最大也允许2300 gas它运行失败时会返回一个布尔值,call转移所有gas所以它没有gas上限,并且和send一样返回一个布尔值,表示其运行成功,或者失败。

目前来说,call是最推荐的发送和接收以太币或其他区块链原生货币的方式。

现在,如果你对这部分还是有些疑惑,那就只看这里就行,这就是我们发送和转移 ETH或其他区块链原生通证的方式。

// call
(bool callSuccess, ) =  payable(msg.sender).call{value: address(this).balance}("");
require(callSuccess, "Call failed");

代码:

// Get funds from users
// Withdraw funds
// Set a minimum funding value in ETH

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract FundMe {

    uint256 public minimumETH = 0.01 * 1e18; // 1 * 10 ** 18

    address[] public funders;
    mapping(address => uint256) public addressToAmountFunded;
    
    function fund() public payable {
        // Want to be able to set a minimum fund amount in currency
        // 1. How do we send ETH to this contract?
        require(msg.value >= minimumETH, "Didn't send enough!");
        funders.push(msg.sender);
        addressToAmountFunded[msg.sender] = msg.value;
    }

    function withdraw() public {
        /* starting index, ending index, step amount */
        for (uint256 funderIndex = 0; funderIndex < funders.length; funderIndex++) {
            // code
            address funder = funders[funderIndex];
            addressToAmountFunded[funder] = 0;
        }
        // resret the array
        funders = new address[](0);
        (bool callSuccess, ) =  payable(msg.sender).call{value: address(this).balance}("");
        require(callSuccess, "Call failed");
    }
}

构造函数

如果我们点击编译FundMe.sol,我们确实看到它通过了编译。

不过,这里还有一个小问题,现在,无论是谁都可以从这个合约提款,任何人都可以出资,这是我们想要的。但我们不希望随便谁都能提款,我们只想让那个募集资金的人能够真正提取资金。

所以我们该怎么设置,才能够让这个withdraw函数只能被合约的owner调用呢?

为此,我们要创建一组新函数,所以,当我们部署这个合约时,我们想让它自动进行设置,这样,无论是谁部署了这个合约都将成为合约的owner,然后我们就可以用一些参数,设定只有合约的拥有者才能调用withdraw函数,所以我们该怎么做呢?

也许我们可以创建一个这样的函数,称为callMeRightAway,然后在我们部署合约后,立刻调用这个callMeRightAway函数,这样我们就能成为合约的拥有者了,不过这样就必须发起两次交易(部署一次,调用函数一次),如果我们不得不这样做的话,那未免也太麻烦了。

因此,solidity 提供了一种称为构造函数(constructor)的东西,如果你熟悉其它编程语言的话,这个构造函数与其它编程语言的完全相同,构造函数的调用机制是这样的,它会在你部署合约后立即调用一次,所以,如果我要部署FundMe.sol,并且在这里写下minimumETH = 2,minimumETH就不再是0.01乘10的18次方了,它会立即更新为2,这是因为构造函数在创建合约的那笔交易中被立刻调用了一次。

contract FundMe {
    uint256 public minimumETH = 0.01 * 1e18; // 1 * 10 ** 18
    constructor() {
        // minimumETH = 2;
    }
}

这个构造函数对我们来说有很大的用处,因为它允许我们以我们想要的方式设置合约,以我来们创建一个全局变量address public owner,然后在我们的构造函数内,我们让owner等于msg.sender,这个构造函数内的msg.sender,就是部署这个合约的人。

contract FundMe {

    uint256 public minimumETH = 0.01 * 1e18; // 1 * 10 ** 18

    address[] public funders;
    mapping(address => uint256) public addressToAmountFunded;
    
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    function fund() public payable {
        // Want to be able to set a minimum fund amount in currency
        // 1. How do we send ETH to this contract?
        require(msg.value >= minimumETH, "Didn't send enough!");
        funders.push(msg.sender);
        addressToAmountFunded[msg.sender] = msg.value;
    }

    function withdraw() public {
        /* starting index, ending index, step amount */
        for (uint256 funderIndex = 0; funderIndex < funders.length; funderIndex++) {
            // code
            address funder = funders[funderIndex];
            addressToAmountFunded[funder] = 0;
        }
        // resret the array
        funders = new address[](0);
        (bool callSuccess, ) =  payable(msg.sender).call{value: address(this).balance}("");
        require(callSuccess, "Call failed");
    }
}

Modifier

现在我们已经设置好了合约的拥有者,现在我们就可以来修改我们的withdraw函数,使得只有拥有者才有权调用这个withdraw函数,所以在withdraw函数的顶部,或许我们想要添加一部分,也许可以这样,require(msg.sender == owner)。

关于双等号与等号这里说明一下,你可以把这个单等号视作一个设置参数,所以构造函数中的=就是把owner 设置为msg.sender,双等号则是用来检查这两个变量是否等价,所以这里==就是在问msg.sender和owner是否是一样的,所以==是检查是否等价,=是设置。

所以我们写下的就是require msg.sender是否等于owner,不是的话,我们抛出一个错误,就说"Sender is not owner!”完美!

    function withdraw() public {
        require(msg.sender == owner, "Sender is not owner!");
        /* starting index, ending index, step amount */
        for (uint256 funderIndex = 0; funderIndex < funders.length; funderIndex++) {
            // code
            address funder = funders[funderIndex];
            addressToAmountFunded[funder] = 0;
        }
        // resret the array
        funders = new address[](0);
        (bool callSuccess, ) =  payable(msg.sender).call{value: address(this).balance}("");
        require(callSuccess, "Call failed");
    }

我们通过这个简单的方法保证了withdraw函数只能被这个合约的拥有者所调用。

现在我们假设这个合约有很多函数,它们都要求只能由合约的拥有者来调用,或者也可能是这个合约有大量函数,它们有着很多各不相同的要求,我们肯定不想反复复制这一行到所有的函数中去,所以我们该怎么办?

这个时候就需要用到修饰器(modifier)了。

现在我们要删掉这一行,并且在下面,创建一个modifier,modifier是一个可以直接在函数声明中添加的关键字,从而修饰(modify)给函数某些功能。

我们要创建一个modifier并且称之为onlyOwner,然后把我们刚刚在withdraw里写的那一行粘贴过来,在下面我们还要添加一个下划线,现在我就可以把这个叫onlyOwner的修饰器,插入到我们的withdraw函数的函数声明中。

    function withdraw() public onlyOwner {
        /* starting index, ending index, step amount */
        for (uint256 funderIndex = 0; funderIndex < funders.length; funderIndex++) {
            // code
            address funder = funders[funderIndex];
            addressToAmountFunded[funder] = 0;
        }
        // resret the array
        funders = new address[](0);
        (bool callSuccess, ) =  payable(msg.sender).call{value: address(this).balance}("");
        require(callSuccess, "Call failed");
    }

    modifier onlyOwner {
        require(msg.sender == owner, "Sender is not owner!");
        _;
    }

所以这个修饰器到底有什么用?这个函数声明中的onlyOwner,我们可以这么理解,嘿,你这个withdraw函数,在你读取所有内部代码之前,先看看这个修饰器onlyOwner并且优先运行这里的代码,然后再运行下划线里的东西,这里的下划线表示运行余下的代码,此处指的就是调用这个withdraw函数。

实际上,我们就是先运行了这个require语句,然后调用余下的代码。如果把这个require语句,放到下划线的下面,这将告诉我们的函数,先运行withdraw函数这里的全部代码,然后再运行这个require,不过我们还是想把这个require 放到前面。

所以这就是修饰器(modifier)的工作机制。

代码:

// Get funds from users
// Withdraw funds
// Set a minimum funding value in ETH

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;

contract FundMe {

    uint256 public minimumETH = 0.01 * 1e18; // 1 * 10 ** 18

    address[] public funders;
    mapping(address => uint256) public addressToAmountFunded;
    
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    function fund() public payable {
        // Want to be able to set a minimum fund amount in currency
        // 1. How do we send ETH to this contract?
        require(msg.value >= minimumETH, "Didn't send enough!");
        funders.push(msg.sender);
        addressToAmountFunded[msg.sender] = msg.value;
    }

    function withdraw() public onlyOwner {
        /* starting index, ending index, step amount */
        for (uint256 funderIndex = 0; funderIndex < funders.length; funderIndex++) {
            // code
            address funder = funders[funderIndex];
            addressToAmountFunded[funder] = 0;
        }
        // resret the array
        funders = new address[](0);
        (bool callSuccess, ) =  payable(msg.sender).call{value: address(this).balance}("");
        require(callSuccess, "Call failed");
    }

    modifier onlyOwner {
        require(msg.sender == owner, "Sender is not owner!");
        _;
    }
}

现在你可以尝试在测试网上部署这个合约了!!!

后记

到这里,博主的Solidity基础部分算是整理完了,但实际上,这并不完善,有些复杂的知识点,博主将之进行了省略。

接口 & 喂价从GitHub中引入& NPMSolidity 中的浮点数计算SafeMath库,溢出检查,和“unchecked”关键词

此外,还有些许进阶知识,也没有写出来。

概念型知识Immutable & ConstantCustom ErrorReceive & Fallback

正如博主开头所叙,此文章参考了课程:freeCodeCamp S2 - YouTube( Lesson 2 Pt. 1~Lesson 4 Pt. 18),如果你想完整地学习Solidity基础知识,请您去看这份视频,这份视频讲的十分完善和详细,耐心看下去,会有很大收获的。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐