第二章:以太坊智能合约内容、数据类型的认识

4 篇文章 0 订阅
3 篇文章 0 订阅


学习内容:智能合约的结构内容与solidity的数据类型的学习

Solidity 文件结构

合约版本声明

pragma solidity ^0.4.0;

引入其他源文件

Solidity 支持 import 语句。
全局引入,引入形式如下:
import ”filename";
自定义命名空间号引入,引入形式如下:
import * as symbol Name from "filename";
创建一个全局的命名空间 symbolName,成员来自 filename 的全局符号。 有一种非 ES6 兼容的简写语法与其等价:
import ”filename" as symbolName;
分别定义引入,引入形式如下:
import {symboll as alias, symbol2} from "filename";
将创建一个新的全局变量别名 alias 和 symbol2,它们将分别从 filename 引 入 symboll 和 symbol2。

合约结构

Solidity 合约的定义和面向对象语言中的类很相似,每个合约都可以包含状态变量、函数、函数修改器、事件、 结构类型和枚举类型。另外,合约也支持继承。
状态变量(State Variable)
状态变量和其他语言中类的成员变量很相似,状态变量会被永久存储在合 约的存储空间里。
在这里插入图片描述
函数(Function)、函数修改器(Function Modifier)
事件(Event)
事件是以太坊虚拟机 (EVM)日志基础设施提供的一个便利接口,用于获 取当前发生的事件。
在这里插入图片描述
结构类型(Struct Type)、枚举类型(Enum Type)
枚举类型是用户创建的包含几个特定值的集合的自定义类型。
在这里插入图片描述
大概的智能合约结构图如下:
在这里插入图片描述

Solidity数据类型

Solidity 是一种静态类型语言, 这一章我们将深入介绍 Solidity 的数据类型。
静态类型意味着在编译时需要为每个变量(本地或状态变量)都指定类型(或至少可以推导出类型)。Solidity 的类型非常在意所占空间的大小
引用类型: 数组(Array)、结构体(Struct)和映射(Mapping)。
布尔类型(Boolean)(省略)

整形(Integer)

当使用移位时,不能进行负移位,即运算符右边的数不可以为负数,否则会抛出运行 时异常,如 3 >>-1 为非法的。
整型溢出问题

在使用整型时, 要特别注意整型的大小及所能容纳的最大值和最小值,很 多合约就是因为溢出问题导致了漏洞。
在这里插入图片描述
避免溢出的一个方法是在运算之后对结果值进行一次检查, 比如对上面的 k 做一个检查,如使用 assert(k >= i)。

定长浮点型(Fixed Point Number)

定长浮点型的声明方式如下:
在这里插入图片描述
fixed/ufixed 表示有符号和无符号的固定位浮点数。关键宇为 ufixedMxN 和 ufixedMxN
注意:它和大多数语言的 float 和 double 不一样,这里的 M 表示整个数占 用的固定位数,包含整数部分和小数部分。

定长字节数组(Fixed-size Byte Array)

移位运算和整型类似,移位运算结果的正负取决于运算符左边的数,且不 能进行负移位。 例如可以-5<<1 ,不可以 5<<-1 。定长字节数组有成员变量:.length,表示这个字节数组的长度(不能修改)。

有理数和整型常量(Rational and Integer Literal)

整型常量是由一系列 0~9 的数字组成的,以十进制数表示。 比如: 八进制 数是不存在的 ,前置 0 在 Solidity 中是无效的。
十进制小数常量 (Decimal Fraction Literal )带了一个“,在“”的两边 少有一个数字,有效的表示如 1..11.3 等。 只要操作数是整型,整型支持的运算符就适用于整型常量表达式。 如果两 个操作数都是小数,则不允许进行位运算,指数也不能是小数。
注意:“Solidity 的每一个数字常量都有对应的数字常量类型, 整型常量和有理数常量属于数字常量类型。 所有的数字常量表达式的结果都是数字常量。 在数字常量表达式中一旦含有非常量表达式,它就会被转换为非常量类型。
不同类型之间没法进行运算,因此下面的代码会编译出错。
在这里插入图片描述
上述代码编译不能通过,因为 b 会被编译器认为是小数。

字符串常量(String Literal)

在这里插入图片描述

字符串常量支持转义字符, 比如\n、\xNN、 \uNNNN。其中\xNN 表示十六 进制值,最终会转换为合适的字节数组; 而\uNNNN 表示 Unicode 编码值, 最 终会转换为 UTF8 的序列。 注意: 字符串常量不支持任何运算符,比如在其他语言中可以通过"+"来 拼接两个字符串常量,但是在 Solidity 中是不可以的。

十六进制常量(Hexadecimal Literal)

十六进制常量以关键宇 hex 开头,后面紧跟用单引号或双引号包裹的字符 串 ,内容是十六进制字符串。
在这里插入图片描述

枚举(Enum)

在 Solidity 中枚举可以用来自定义类型。 它可以与整数进行显式转换,但不能进行隐式转换。 显式转换会在运行时检查数值范围 ,如果不匹配将会引发异常。 枚举类型应至少有一个成员 。 下面是一个枚举的例子。

在这里插入图片描述

函数类型(Function Type)

Solidity 中的函数也可以是一种类型且属于值类型。

函数类型有两类:内部( internal) 函数和外部(external)函数。
内部函数只能在当前合约内被调用(在当前代码块内,包括内部库函数和继承的函数),它不会创建一个 EVM 消息调用,访问方式是直接使用函数名f()。
外部函数通过 EVM 消息调用,它由地址和函数方法签名两部分组成,访 问方式是 this.f()。 外部函数可作为外部函数调用的参数或返回值。
函数类型默认是 internal, 因此 internal 可以省去。但与此相反,合约中函数本身默认是 public 的,仅仅是当作类型名使用时默认才是 internal。如果应该 使用 internal,却使用了 external,则很容易引发安全问题,同时也增加了 gas 的消耗。 如果应该 使用 internal,却使用了 external,则很容易引发安全问题,同时也增加了 gas 的消耗。

注意一下,声明一个函数和声明一个函数类型的变量是不一样的, 后面会继续介绍。
有两种方式访问函数, 一种是直接用函数名 f,另一种是用 this.f。 前者用于内部函数, 后者用于外部函数。 合约中的 public 的函数, 可以使用 internal 和 external 两种方式来调用 。 internal 访问形式为f, external 访问形式为
this.f。

selector 成员属性

公有或外部(public /external)函数类型有一个特殊的成员属性 selector,它 对应一个 ABI 函数选择器,后续会继续讲解。(这里只要知道它是一个函数签名即可。)
在这里插入图片描述
下面的代码显示内部( internal )函数类型的使用:

pragma solidity ^0.4.16;
library ArrayUtils {
  // 内部函数可以在内部库函数中使用,
  // 因为它们会成为同一代码上下文的一部分
  function map(uint[] memory self, function (uint)  returns (uint) f)internal returns (uint[] memory r){
    // r=[0,1,4,9]
    r = new uint[](self.length);
    for (uint i = 0; i < self.length; i++) {
      r[i] = f(self[i]);
    }
  }
  // self 因为函数调用是r.函数,所以self可以理解为第一个参数调用
  function reduce(uint[] memory self,function (uint, uint)  returns (uint) f)internal returns (uint r){
    r = self[0];
    for (uint i = 1; i < self.length; i++) {
      r = f(r, self[i]);
    }
  }
  function range(uint length) internal returns (uint[] memory r) {
    r = new uint[](length);
    for (uint i = 0; i < r.length; i++) {
      r[i] = i;
    }
  }
}

contract Pyramid {
  using ArrayUtils for *;
  function pyramid(uint l) public view returns (uint) {
    return ArrayUtils.range(l).map(square).reduce(sum);
  }
  function square(uint x) internal  returns (uint) {
    return x * x;
  }
  function sum(uint x, uint y) internal returns (uint) {
    return x + y;
  }
}

下面的代码显示外部(external)函数类型的使用:

pragma solidity ^0.4.11;

contract Oracle {
    struct Request {
        bytes data;
        function(bytes memory) external callback;
    }
    
    Request[] requests;
    event NewRequest(uint);
    function query(bytes data,function(bytes memory) external callback) public {
        requests.push(Request(data,callback));
        NewRequest(requests.length - 1);
    }
    function reply(uint requestID,bytes response) public {
        // 这里检查回复是否来自可信来源
        requests[requestID].callback(response);
    }
}

contract OracleUser {
    Oracle constant oracle = Oracle(0x425148e9e030d8ae44da54458cd577218e1bea39); // 已知合同
    function buySomething(){
        oracle.query("USD",this.oracleResponse);
    }
    function oracleResponse(bytes response) public {
        require(msg.sender == address(oracle));
        // 使用的数据
    }
}

public 还是 external? public 和 external 看上去有很多相似的地方,那么到底该用哪一个呢? 我们以下面一段代码为例,来看看 public 与 external 的不同:
我们看下面这个例子:

pragma solidity ^0.4.18;

contract Test {
    uint[10] x = [1,2,3,4,5,6,7,8,9,10];
    function test(uint[10] a) public returns (uint) {
        return a[8] * 2;
    }
    
    function test2(uint[10] a) external returns (uint) {
        return a[8] * 2;
    }
    
    function calltest() {
        test(x);
    }
    
    function calltest2() {
        this.test2(x);
    }
}

在这里插入图片描述
可以看到调用 public 函数花销更大。当使用 public 函数时, Solidity 会立即复制数组参数到内存, 而 external 函 数则是从 calldata 读取的, 分配内存的开销比直接从 calldata 读取的开销要大得多。那为什么 public 函数要复制数组参数到内存呢? 因为 public 函数可能会被内部调用 ,而内部调用数组的参数是当成指向一块内存的指针。 对于 external 函数不允许内部调用 , 它直接从 callda阅 读取数据, 省去了复制的过程。
同样, 我们接着对比 calltest()及 calltest2(),会有一个花费很大开销的 CALL 调用,并且它传参的方式也 比内部传递的开销更大。 【这是因为通过 this.f()模式调用 】

地址类型(Address)

地址类型是一个值类型。 地址占用 20 字节, 即 以太坊地址的长度是 20 个字节。

目前,地址是所有合约的基础 (基类),即合约也可以是一个类型并且继承自地址类型。 不过官方文档说,从 Solidity 0.5.0 版本开始,合约将不再继承自地址类型,但会保留显式转换为地址。

地址类型的成员

balance 属性及transfer()函数
balance 用来查询账户余额, transfer()用来发送以太币。addr.balance 用来查询账户 addr 的余额, addr.transfer() 用来向地址 addr 发送以太币(注意,很多人误以为 addr 是发送方,但实际上 addr 是接收方)。
注解: 如果 x 是合约地址,那么合约的回退函数(fallback)会随 transfer 调用一起执行(这个是 EVM 特性)。如果因 gas 耗光或其他原因失败,则转移交易会被还原, 并且合约会出现异常并停止。
sender()函数
send 与 transfer 对应,但在底层上。如果执行失败, transfer 不会因异常停止,而 send会返回 false。实际上 addr.transfer(y)与 require(addr. send(y))是等价的。
如果交易失败,则会退回以太币 。

警告: send()执行有一些风险。如果调用栈的深度超过 1024或gas耗光,交易都会失败。

call()、 delegatecall()、 callcode()函数这里不赘述。因为以后可能会被移除,且安全性低,容易锁攻击,想了解请自行百度。

如何区分合约地址及外部账号地址

EVM 提供了一个操作码 EXTCODESIZE,用来获取地址相关联的代码大小(长度),如果是外部账号地址, 则没有代码返回 。
在这里插入图片描述
如果是在合约外部判断,则可以使用 web3.eth.getCode() , 或者是对应的 JSON-RPC 方法 eth_getcodea
getCode()用来获取参数地址所对应合约的代码,如果参数是一个外部账号 地址,则返回”0x”;如果参数是合约,则返回对应的字节码, 如下所示:
在这里插入图片描述
这样我们就可以通过 getCode()的内容判断是哪一种地址了。

数据位置(Data Location)

所有的复杂类型如数组和结构体都有一个额外属性:数据的存 储位置,即 memorystorage

局部变量:局部作用域(越过作用域即不可被访问,等待被回收)的变量,如函数内的变量。 状态变量:合约内声明的公有变量。

还有一个存储位置是 calldata,用来存储函数参数,是只读的、不会永久存储的数据位置。 外部函数的参数(不包括返回参数)被强制指定为 calldatao 效 果与 memory 差不多。 将一个 storage 的状态变量,赋值给一个 storage 的局部变量,则是通过引用传递完成的。所以对于局部变量的修改,要同时修改关联的状态变量。 另一方面,将 一个 memory 的引用类型赋值给另一个 memory 的引用,是不会创建拷贝的, 即 memory 之间是通过引用传递完成的。

  1. 不能将 memory赋值给局部变量。
  2. 对于值类型,总是会进行拷贝的。
强制指定的数据位置(Forced data location)
  • 外部函数的参数(不包括返回参数)强制指定的数据位置为 calldata。
  • 状态变量强制指定的数据位置为 storage
默认的数据位置(Default data location)
  • 函数参数及返回参数默认的数据位置为 memory。
  • 复杂类型的局部变量默认的数据位置为 storage。

memory 只能用于函数内部, memory 声明用来告知 EVM 在运行时创建一 块(固定大小)内存区域给变量使用 。

storage 在区块链中时用key/value的形式存储的,而memory则表示为字节数组。

关于栈(stack)

EVM 是一个基于栈的语言,而栈实际上是内存( memory) 中的一个数据 结构。每个栈元素为 256 位,拔的最大长度为 1024。

不同存储的 gas 消耗
  • storage 会永久保存合约状态变量, 开销最大。
  • memory 仅保存临时变量,函数调用之后释放,开销很小。
  • stack 保存很小的局部变量,几乎免费使用 ,但有数量限制。

数组(Array)

数组可以在声明时指定长度,也可以动态变长。一个元素类型为 T, 固定 长度为 k 的数组,可以声明为 T[k];而一个动态变长的数组,可以声明为 T[]。
在这里插入图片描述

或者用 new 关键字进行声明,形式如下:
在这里插入图片描述

注意,在其他语言中,多维数组的长度声明是反的。比如用java声明一个包含5 个元素、 每个元素都是数组的方式为 int[5][]。

对存储在 storage 的数组来说, 元素类型可以是任意的,类型可以是数组、 映射类型、 结构体等。但对于存储在 memory 的数组来说,如果它是 public 函数的参数,则不能是映射类型的数组,只能是支持 ABI 的数组类型


创建内存数组
可以使用 new 关键宇创建一个存储在 memory 上的数组。与存储在 storage 上的数组不同的是, 该数组不能通过成员.length 的值来修改数组的大小属性。 memory不是new的数组也是不能修改数组的大小属性。


pragma solidity ^0.4.16;

contract C {
    function f(uint len) public pure {
        uint []memory a;
        bytes memory b = new bytes(len);
        a[6] = 8;
    }
}

数组常量及内联数组
数组常量,是一个数组表达式(还没有赋值到变量)。


还需注意的一点是,定长数组不能与变长数组相互赋值,我们来看下面的 错误代码示例 :

pragma solidity ^0.4.4;
contract C {
    function f() public {
        uint8[] x = [uint8(1),uint8(3),uint8(4)];
    }
}

不过, Solidity 己经计划在未来移除这样的限制。 当前是因为 ABI 传递数组, 所以还有些问题。

数组成员

length 属性

数组有一个length 的成员属性,表示当前的数组长度。 对于存储在 storage 的变长数组,可以通过给.length 赋值调整数组长度。而存储在 memory 的变长数组不支持修改.length 调整数组长度。

push 方法

存储在 storage 的变长数组和 bytes 都有一个 push 成员方法(string 没有), 用于附加新元素到数据末端,返回值为新的长度。
注意, string 没有 push 方法,存储在 memory 的数组也不支持 pusha

pragma solidity ^0.4.16;

contract ArrayContract {
    uint[2**20] m_aLotOfIntegers;
    bool[2][] m_pairOfFlags;
    
    function selFlagPair(uint index,bool flagA,bool flagB) public {
        // 访问不存在的 index 会抛出异常
        m_pairOfFlags[index][0] = flagA;
        m_pairOfFlags[index][1] = flagB;
    }
    
    // 改变storage的数组大小
    function changeFlagArraySize(uint newSize) public {
        m_pairOfFlags.length = newSize;
    }
    
    function clear() public {
        delete m_pairOfFlags;
        delete m_aLotOfIntegers;
        // 同样是销毁效果 销毁只是把值清除
        // m_pairOfFlags.length = 0;
    }
    
    bytes m_byteData;
    
    function byteArrays(bytes data) public returns (byte){
        m_byteData = data;
        // 改数组长度
        m_byteData.length += 7;
        m_byteData[3] = byte(8);
        delete m_byteData[2];
        return m_byteData[2];
    }
    
    function addFlag(bool[2] flag) public returns (uint) {
        return m_pairOfFlags.push(flag); // storage
    }
    
    function createMemoryArray(uint size) public pure returns (bytes) {
        uint[2][] memory arrayOfPairs = new uint[2][](size);
        bytes memory b = new bytes(200);
        for (uint i = 0; i < b.length; i++) {
            b[i] = byte(i);
        }
        return b;
    }
}

字符串 string 及字节数组 bytes

bytes 是动态分配大小字节数组, bytes 类似于 byte[],但在外部函数作为参数调用中, bytes 会进行压缩打包。 string 类似于 bytes, 但 目前不提供长度和按 序号的访问方式。 所以应该尽量使用 bytes 而不是 byte[]。
可以将字符串 s 通过 bytes(s)转为一个bytes, 通过bytes(s).length 获取长度, bytes(s)[n]获取对应的 UTF-8 编码。 通过下标访问获取到的不是对应字符,而是 UTF-8 编码,比如中文的编码是变长的多字节,因此通过下标访问中文字符串 得到的只是其中的一个编码。

string 扩展

Solidity语言本身提供的 string 功能比较弱, 因此有人实现了 string 的实用 工具库 stringutils, GitHub 地址为 https://github .com/ Arachnid/solidity-stringutils, 并且在这个库中引入了一个 slice 的概念,下面列举了几个使用示例。 【具体去官方查看】

定长字节数组还是字符串

有时定长字节数组会用来代替字符串使用,这是为什么呢?我们先来对比 以下不同函数的 gas 消耗:

pragma solidity ^0.4.16;

contract compGas {
    string constant ss = "Tiny Xiong";
    bytes32 constant bt32 = "Tiny Xiong";
    
    function getBytes32() payable public returns (bytes32) {
        return bt32;
    }
    
    function getString() payable public returns (string) {
        return ss;
    }
}

我在本地测试时, getByte32()消耗了 21490 gas, getString()消耗了 21853 gas。 相比变长的 string, 定长字节数组 gas 消耗更少。 因此,如果宇符串的长度是固 定的(或长度可以确定),尽量使用 bytes32 (官方文档里介绍的是尽量使用定 长的如 bytesl 到 bytes32 中的一个,因为更省空间,我的个人经验是使用 bytes32 消耗的 gas 最少)。

结构体(Struct)

声明与初始化

1.仅声明变量而不初始化,此时会使用默认值创建结构体变量,例如:CustomType ctl;
2. 按成员顺序(结构体声明时的顺序) 初始化,例如:

CustomType ctl = CustomType(true, 2); //只能作为状态变量这样使用 
CustomType memory ct2 = CustomType(true, 2); //在函数内声明

这种方式需要特别注意参数的类型及数量的匹配。另外,如果结构体中有 mapping,则需要跳过对 mapping 的初始化。 例如对上面 CustomType3 的初始化方法为:

CustomType3 memory ct = CustomType3(”tiny", 2); 

3.3 . 命名方式初始化。
使用命名方式可以不按定义的顺序初始化,初始化方法如下:
//使用命名变量初始化

CustomType memory ct = CustomType({ myBool: true, myint: 2}); 

参数的个数需要保持和定义时一致, 如果有 mapping 类型, 则同样需要忽略。
下面是结构体的例子。

pragma solidity ^0.4.16;

contract CrowdFunding {
    // 出资人
    struct Funder {
        address addr;
        uint amount;
    }
    // 
    struct Campaign {
        address beneficiary;// 受益人
        uint fundingGoal; // 出资目标
        uint numFunders; // 出资人数
        uint amount;    // 出资总金额
        mapping (uint => Funder) funders;
    }
    
    uint numCampaigns;// 订单数
    mapping (uint => Campaign) campaigns;
    
    // 新建出资订单
    function newCampaign(address beneficiary,uint goal) public returns (uint campaignID){
        campaignID = numCampaigns++;
        // 创建一个结构体实例,存储在storage上,放入mapping里
        campaigns[campaignID] = Campaign(beneficiary,goal,0,0);
    }
    // 贡献
    function contribute(uint campaignID) public payable {
        Campaign storage c = campaigns[campaignID];
        c.funders[c.numFunders++] = Funder({addr:msg.sender,amount:msg.value});
        c.amount += msg.value;
    }
    // 
    function checkGoalReached(uint campaignID) public returns (bool reached) {
        Campaign storage c = campaigns[campaignID];
        if (c.amount < c.fundingGoal)
            return false;
        uint amount = c.amount;
        c.amount = 0;
        c.beneficiary.transfer(amount);
        return true;
    }
}

注意在函数中,将一个 struct 赋值给一个局部变量(默认是 storage 类型),实际是拷贝的引用,所以修改局部变量值的同时会影响到原变量。
结构体也可以直接通过访问成员来修改成员的值

结构体限制

结构体目前仅支持在合约内部使用或继承合约内使用,如果要在参数和返 回值中使用结构体,函数必须声明 internal

//合法 function interFunc(CustomType ct) internal { 
}
//非法
手unction exterFunc(CustomType ct) public { 
}

映射(Mapping)

限制
映射类型仅能用来作为状态变量,或者在内部函数中作为 storage 类型的引用。
映射并未提供选代输出的方法,即不能通过遍历访问所有元素,也无法获得所有键或值的列表。 如果需要使用,我们可以自行实现一个这样的数据结构。 参考以下链接, http://github.com/ethereum/dapp-bin/bIob/master/library/Iiterab Ie_mapping.sol。
类型转换
隐式转换与显式转换[没啥好说]

var 类型推导

函数的参数包括返回参数,不可以使用 var 这种不指定类型的方式。
有一个地方需要注意,由于类型推导是根据第一个变量进行的赋值,所以 代码 for (var i = 0; i < 2000; i++) {}将是无限循环的,因为一个 uint8 的 i 将小于2000。

delete 操作符

在 Solidity 中, delete 操作符的功能尽管也可以释放空间,但 delete 操作符更像是将某个变量重置为初始值,例如 delete a 对于整数 a, 效果等同于 a = 0。
如果 delete 作用到数组上,则是把数组中的每个元素设置为初始值。变长数组则是将长度设置为 0。 对于结构体也是一样的,是将所有的成员均重置为初始值。
delete 对于映射类型几乎无影响,如果你删除一个结构体,它会递归删除所有非 mapping 的成员。 当然,你可以单独删除映射里的某个键, 以及这个键映射的某个值。

mapping(address= >uint) balances; 
function deleteMap() public { 
	delete balances; 
	delete balances[msg.sende];
}

如果删除一个结构体,那么它会递归删除所有非 mapping 的成员。

pragma solidity ^0.4.0; 
contract DeleteExample { 
	uint data; 
	uint[] dataArray;
	function f() public {
		// 值传递
		uint x = data;
		delete x; // 删除x不会影响data
		delete data; // 删除data同样不会影响,因为是值传递,保存的是值拷贝
		// 引用赋值
		uint[] storage y = dataArray;
		//删除dataArray会影响y,y将被赋值为初值
		detele dataArray;
		// 报错,因为删除的是一个赋值操作,所以不能向引用类型的storage直接赋值
		// delete y;
	}

通过上面的代码,我们可以看出对于值类型是值传递的,删除 x 不会影响 到 data。 同样,删除 data 也不会影响到 x。 因为它们都存了一份原值的拷贝 。
由于 delete 的行为更像是赋值操作,所以不能在上述代码中执行 delete y。 我们不能对一个 storage 的引用赋值。
另外,关于 delete 的 gas 消耗有一个看起来矛盾的地方,即在清理空间的 时候是可以获得 gas 的返还的。 但因为 delete 操作本身消耗 gas,所以在实际使用时最好进行 gas 消耗的对比。

第一章:以太坊智能合约的学习 https://blog.csdn.net/qq_44423523/article/details/108261794
第三章:以太坊智能合约单位、表达式、全局变量及函数、控制结构https://blog.csdn.net/qq_44423523/article/details/108309371

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值