如何在 Solidity 中对数组进行去重

一、引言

Solidity 是一种面向以太坊平台的智能合约编程语言,具有类似 JavaScript 和 C++ 的语法结构。它是专门为在区块链上编写自执行合约而设计的,支持复杂的业务逻辑和去中心化应用(dApps)的开发。随着区块链技术的快速发展,Solidity 已成为构建去中心化金融(DeFi)、NFT 市场以及其他区块链应用的首选语言(其实主要是以太坊用户多罢了)。

在区块链开发中,处理数据的效率至关重要,特别是在智能合约中,数组的高效操作往往决定了合约的性能和 gas 成本。由于以太坊网络上的每一笔交易都会产生费用,减少不必要的计算和存储操作变得尤为关键。对数组进行去重就是这样一种常见的数据操作需求:我们可能需要从一个用户列表中移除重复地址,或从一个交易列表中提取唯一的交易 ID。这些操作不仅涉及数据的正确性,还直接影响到合约的执行成本。

那么,在 Solidity 中,如何高效地对数组进行去重?这是一个值得深入探讨的话题。本文将介绍几种常见的去重方法,并分析它们的优缺点,帮助你在实际开发中选择最合适的策略。

二、Solidity 中的数组操作基础

在 Solidity 中,数组是最常用的数据结构之一,允许开发者存储和操作一系列相同类型的元素。根据数组的长度是否固定,Solidity 中的数组可以分为静态数组动态数组

2.1 Solidity 中数组的基本使用方法

在 Solidity 中,定义和使用数组的方法非常直观。数组类型由元素类型和方括号组成,例如 uint256[] 表示一个动态数组,uint256[5] 表示一个包含 5 个 uint256 元素的静态数组。

以下是一些基本的数组操作示例:

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

contract ArrayExample {
    // 动态数组
    uint256[] public dynamicArray;

    // 静态数组
    uint256[5] public staticArray;

    // 添加元素到动态数组
    function addElement(uint256 element) public {
        dynamicArray.push(element);
    }

    // 修改静态数组中的元素
    function setElement(uint256 index, uint256 element) public {
        require(index < staticArray.length, "Index out of bounds");
        staticArray[index] = element;
    }

    // 获取动态数组的长度
    function getDynamicArrayLength() public view returns (uint256) {
        return dynamicArray.length;
    }
}

2.2 动态数组与静态数组的区别

  1. 静态数组:静态数组的长度在编译时已确定,并且在合约生命周期中无法更改。使用静态数组的优点是它们的存储和操作成本相对较低,因为它们不需要动态调整大小。静态数组常用于合约中需要处理固定数量数据的场景,例如固定数量的参与者或预定义的常量值。

    // 定义一个包含 3 个元素的静态数组
    uint256[3] public staticArray = [1, 2, 3];
    
  2. 动态数组:动态数组的长度在合约的生命周期内是可变的,开发者可以使用 push 方法向数组中添加元素。虽然动态数组提供了灵活性,但它们也带来了更高的 gas 成本,尤其是在添加和删除元素时。动态数组适用于需要处理可变数量数据的场景,例如用户地址列表或交易记录等。

    // 定义一个动态数组
    uint256[] public dynamicArray;
    
    // 向动态数组中添加元素
    dynamicArray.push(4);
    

2.3 数组操作的 gas 成本及其在智能合约中的重要性

在智能合约中,每次数组操作都会消耗一定的 gas,这是因为操作涉及对以太坊虚拟机(EVM)中存储的读取和写入。尤其是在以太坊主网上,gas 成本直接影响到交易费用,因此对数组的操作效率显得尤为重要。

  • 读操作:在数组中读取数据的 gas 成本相对较低,通常只需要访问存储器。
  • 写操作:写操作的 gas 成本较高,因为它涉及到将数据写入以太坊的持久化存储。
  • 动态调整大小:对于动态数组,每次 push 操作不仅需要写入新元素,还可能涉及数组大小调整的操作,这会增加额外的 gas 成本。

优化数组操作是 Solidity 开发中的一个关键点。为了减少不必要的 gas 消耗,开发者通常会在合约逻辑中慎重考虑数组的使用方式和操作方法。例如,尽量避免在循环中进行多次写操作,或者在不必要的情况下使用动态数组。

总之,理解数组的基本操作及其 gas 成本,可以帮助开发者编写更高效的智能合约,避免不必要的资源浪费,提升合约的整体性能。

三、Solidity 中的去重挑战

在智能合约开发中,Solidity 的局限性往往会影响开发者实现特定功能的方式。一个显著的限制是,Solidity 不直接支持像 JavaScript 中的 Set 这样的动态数据结构。这使得在 Solidity 中处理集合操作(如去重)变得更加复杂和昂贵。

由于 Solidity 的局限性和区块链环境的特性,开发者必须在实现去重的同时,尽可能减少存储和计算操作,以节省 gas 和降低存储成本。这需要精心设计合约逻辑,选择最合适的数据结构和算法来优化性能和成本。在智能合约开发中,这种权衡和优化是不可避免的,也是开发者需要不断学习和改进的关键点。

3.1 Solidity 的局限性

  1. 缺乏高级数据结构:Solidity 目前只支持基本的数据结构,如数组和映射(mapping)。这些数据结构虽然足以满足许多简单需求,但在处理更复杂的数据操作时,如自动去重或排序,它们显得力不从心。与 JavaScript 不同,Solidity 没有原生的 Set 类型,这意味着没有直接的方式来存储唯一值。
  2. 存储操作成本高:Solidity 中的任何写入操作,特别是涉及到永久存储(storage)时,都会消耗大量的 gas。存储的数据越多,操作越复杂,消耗的 gas 就越高。因此,构建一个复杂的数据结构或进行多次数据写入操作,会显著增加合约的部署和执行成本。
  3. 没有原生的集合操作:Solidity 缺乏对集合操作的原生支持。像在 JavaScript 中使用 Setadd 方法自动去重,或使用 has 方法快速查找元素,这些在 Solidity 中都需要手动实现。通常,这需要编写额外的逻辑和循环,进一步增加了合约的复杂性和执行成本。

3.2 在 Solidity 中实现去重的难度

在 Solidity 中去重的主要难点在于如何在保证数据唯一性的同时控制 gas 成本。以下是实现去重的一些挑战:

  1. 高昂的 gas 成本:为了实现去重,开发者需要遍历数组中的所有元素,并且通常需要在遍历过程中检查每个元素是否已经存在。使用映射(mapping)或集合(set)来跟踪已经见过的元素是一种常见的解决方案,但每次访问和修改映射都会消耗 gas,尤其是在数据量较大时,成本可能会非常高。
  2. 存储空间的浪费:为实现去重,我们需要额外的数据结构来跟踪已经处理的元素。例如,使用映射来记录一个元素是否已出现过,虽然这种方式可以使查找操作的时间复杂度为 O(1),但是映射本身需要额外的存储空间,这会增加合约的总体存储成本。更糟的是,存储在区块链上的数据是永久存在的,这意味着这些额外的存储消耗将会是长期的。
  3. Gas Limit 约束:以太坊网络对单个交易执行的 gas 数量有上限(即 gas limit)。如果一个合约函数在执行时消耗的 gas 超过了这个限制,交易将被回滚,合约不会执行成功。去重操作的复杂性可能导致 gas 消耗迅速增加,特别是在处理大型数组或在复杂逻辑中嵌套多次去重操作时。

四、方法一:使用集合(或映射)进行去重

下面是一个使用 openzepplin 的 EnumerableSet 库来快速去重空投地址的智能合约示例:

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

// version >= v3.3.0
import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";

contract AirdropUniqueAddresses {
    // Using the EnumerableSet library for AddressSet
    using EnumerableSet for EnumerableSet.AddressSet;

    // Declare a state variable of type EnumerableSet.AddressSet to store unique addresses
    EnumerableSet.AddressSet private seen;

    // Function to add an address to the set if it is not already present
    function addAirdropAddress(address _address) public returns (bool) {
        // Add the address to the set
        // Returns true if the address was not already in the set, false otherwise
        return seen.add(_address);
    }

    // Function to check if an address is already in the set
    function isAirdropAddressSeen(address _address) public view returns (bool) {
        // Check if the address is in the set
        return seen.contains(_address);
    }

    // Function to get the total number of unique addresses in the set
    function getTotalUniqueAddresses() public view returns (uint256) {
        // Return the number of unique addresses in the set
        return seen.length();
    }

    // Function to retrieve an address by index in the set
    function getAirdropAddressAtIndex(uint256 index) public view returns (address) {
        // Ensure the index is within the bounds of the set
        require(index < seen.length(), "Index out of bounds");
        // Retrieve the address at the specified index
        return seen.at(index);
    }

    // Function to remove an address from the set
    function removeAirdropAddress(address _address) public returns (bool) {
        // Remove the address from the set
        // Returns true if the address was in the set and removed, false otherwise
        return seen.remove(_address);
    }
}
  • 优点
    • 高效性:映射在 Solidity 中提供了 O(1) 的查找时间。
    • 易于实现:逻辑简单,易于理解。
  • 缺点
    • 需要额外的存储空间,可能会增加 gas 成本。
    • 不能动态创建映射,需要预先定义数据结构:类似这种代码在编译中会报错Uninitialized mapping. Mappings cannot be created dynamically, you have to assign them from a state variable.
mapping(uint256 => bool) memory seen;
uint256[] memory input = [1, 2, 2, 3, 4, 4];
for (uint256 i = 0; i < input.length; i++) {
    if (!seen[input[i]]) {
        seen[input[i]] = true;
    }
}

五、方法二:双重循环去重

以下是一个使用双重循环来去重空投地址的智能合约示例:

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

contract AirdropUniqueAddresses {
    // Function to get the unique addresses after removing duplicates
    function getUniqueAirdropAddresses(address[] airdropAddresses;) public view returns (address[] memory) {
        // Calculate the length of the airdropAddresses array
        uint256 length = airdropAddresses.length;

        // Create a dynamic array to store unique addresses
        address[] memory uniqueAddresses = new address[](length);
        uint256 uniqueCount = 0;

        // Outer loop to iterate through each address in the airdropAddresses array
        for (uint256 i = 0; i < length; i++) {
            bool isDuplicate = false;

            // Inner loop to check if the address is already in the uniqueAddresses array
            for (uint256 j = 0; j < uniqueCount; j++) {
                if (airdropAddresses[i] == uniqueAddresses[j]) {
                    isDuplicate = true;
                    break;
                }
            }

            // If the address is not a duplicate, add it to the uniqueAddresses array
            if (!isDuplicate) {
                uniqueAddresses[uniqueCount] = airdropAddresses[i];
                uniqueCount++;
            }
        }

        // Create a new array with the size of uniqueCount to store the final unique addresses
        address[] memory finalUniqueAddresses = new address[](uniqueCount);

        // Copy the unique addresses to the final array
        for (uint256 k = 0; k < uniqueCount; k++) {
            finalUniqueAddresses[k] = uniqueAddresses[k];
        }

        return finalUniqueAddresses;
    }
}
  • 优点
    • 不需要额外的存储结构,非常节省空间。
    • 适合小规模数据,简洁易懂。
  • 缺点
    • 时间复杂度为 O(n^2),对于大数据集不太适用。
    • 可能导致高 gas 消耗,不建议在生产环境中使用。

六、参考资料

  1. https://docs.soliditylang.org/zh/v0.8.21/types.html
  2. https://docs.openzeppelin.com/contracts/3.x/api/utils#EnumerableSet
  3. https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/structs/EnumerableSet.sol
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 220,458评论 6 513
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,030评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,879评论 0 358
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,278评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,296评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,019评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,633评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,541评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,068评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,181评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,318评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,991评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,670评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,183评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,302评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,655评论 3 375
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,327评论 2 358

推荐阅读更多精彩内容