背景我們Datona Labs在開發和測試Solidity資料訪問合約(S-DAC:Smart-Data-Access-Contract)模板過程中,經常需要使用只有很小數值的小陣列(陣列元素個數少)。在本示例中,研究了使用值陣列(Value Array)是否比引用陣列(Reference Array)更高效。討論Solidity支援記憶體(memory)中的分配陣列,這些陣列會很浪費空間(參考 文件[1]),而儲存(storage)中的陣列則會消耗大量的gas來分配和訪問儲存。但是Solidity所執行的以太坊虛擬機器(EVM)[2]有一個256位(32位元組)機器字長。正是後一個特性使我們能夠考慮使用值陣列(Value Array)。在機器字長的語言中,例如32位(4位元組),值陣列(Value Array)不太可能實用。我們可以使用值陣列(Value Array)減少儲存空間和gas消耗嗎?譯者注:機器字長 是指每一個指令處理的資料長度。比較值陣列與引用陣列引用陣列(Reference Array)在 Solidity 中,陣列通常是引用型別。這意味著每當在程式中遇到變數符號時,都會使用指向陣列的指標,不過也有一些例外情況會生成一個複製(參考文件-引用型別[3])。在以下程式碼中,將10個元素的 8位uint users 的陣列傳遞給setUser函式,該函式設定users陣列中的一個元素:contract TestReferenceArray { function test() public pure { uint8[10] memory users; setUser(users, 5, 123); require(users[5] == 123); } function setUser(uint8[10] memory users, uint index, uint8 ev) public pure { users[index] = ev; }}函式返回後,users陣列元素將被更改。值陣列(Value Arrays)值陣列是以值型別[4]儲存的陣列。這意味著在程式中遇到變數符號,就會使用其值。contract TestValueArray { function test() public pure { uint users; users = setUser(users, 5, 12345); require(users == ...); } function setUser(uint users, uint index, uint ev) public pure returns (uint) { return ...; }}請注意,在函式返回之後,函式的users引數將保持不變,因為它是透過值傳遞的,為了獲得更改後的值,需要將函式返回值賦值給users變數。Solidity bytes32 值陣列Solidity 在 bytesX(X=1..32)型別中提供了一個部分值陣列。這些位元組元素可以使用陣列方式訪問單獨讀取,例如: ... bytes32 bs = "hello"; byte b = bs[0]; require(bs[0] == 'h'); ...但不幸的是,在Solidity 目前的版本[5]中,我們無法使用陣列訪問方式寫入某個位元組: ... bytes32 bs = "hello"; bs[0] = 'c'; // 不可以實現 ...讓我們使用Solidity的 using for[6] 匯入庫的方式為bytes32型別新增新能力:library bytes32lib { uint constant bits = 8; uint constant elements = 32; function set(bytes32 va, uint index, byte ev) internal pure returns (bytes32) { require(index < elements); index = (elements - 1 - index) * bits; return bytes32((uint(va) & ~(0x0FF << index)) | (uint(uint8(ev)) << index)); }}這個庫提供了set()函式,它允許呼叫者將bytes32變數中的任何位元組設定為想要的位元組值。根據你的需求,你可能希望為你使用的其他bytesX型別生成類似的庫。測試一把讓我們匯入該庫並測試它:import "bytes32lib.sol";contract TestBytes32 { using bytes32lib for bytes32; function test1() public pure { bytes32 va = "hello"; require(va[0] == 'h'); // 類似 va[0] = 'c'; 的功能 va = va.set(0, 'c'); require(va[0] == 'c'); }}在這裡,你可以清楚地看到set()函式的返回值被分配回引數變數。如果缺少賦值,則變數將保持不變,require()就是來驗證它。可能的固定長度值陣列在Solidity機器字長為256位(32位元組),我們可以考慮以下可能的值陣列。固定長度值陣列這些是以些Solidity可用整型[7]匹配的固定長度的值陣列: 固定長度值陣列型別 型別名 描述uint128[2] uint128a2 2個128位元素的值陣列uint64[4] uint64a4 4個64位元素的值陣列uint32[8] uint32a8 8個32位元素的值陣列uint16[16] uint16a16 16個16位元素的值陣列uint8[32] uint8a32 32個8位元素的值陣列128位元素: 意思是一個元素佔用128位空間我建議使用如上所示的型別名,這在本文中都會用到,但是你可能會找到一個更好的命名約定。更多固定長度值陣列實際上,還有更多可能的值陣列。我們還可以考慮與Solidity可用型別不匹配的型別,對於特定解決方案可能有用。X(值的位數)乘以Y(元素個數)必須小於等於256: 更多固定長度值陣列型別 型別名 描述uintX[Y] uintXaY X * Y <= 256uint10[25] uint10a25 25個10位元素的值陣列uint7[36] uint7a36 36個7位元素的值陣列uint6[42] uint6a42 42個6位元素的值陣列uint5[51] uint5a51 51個5位元素的值陣列uint4[64] uint4a64 64個4位元素的值陣列uint1[256] uint1a256 256個1位元素的值陣列...特別感興趣的是uint1a256值陣列。這使我們可以將最多256個1位元素值(代表布林值)有效地編碼為1個EVM字長。相比之下,Solidity的bool [256]會消耗256倍的記憶體空間,甚至是8倍的儲存空間。還有更多固定長度值陣列還有更多可能的值陣列。以上是最有效的值陣列型別,因為它們有效地對映到EVM字長中的位。在上面的值陣列型別中,X表示元素所佔用的位數。還有按位移位技術的在算術編碼中使用乘法和除法,但這超出了本文的範圍,可以參考這裡[8]固定長度值陣列實現下面是一個有用的可匯入庫檔案,為值陣列型別uint8a32提供get和set函式:// uint8a32.sollibrary uint8a32 { // 等效於 uint8[32] uint constant bits = 8; uint constant elements = 32; // 確保 bits * elements <= 256 uint constant range = 1 << bits; uint constant max = range - 1; // get 函式 function get(uint va, uint index) internal pure returns (uint) { require(index < elements); return (va >> (bits * index)) & max; } // set 函式 function set(uint va, uint index, uint ev) internal pure returns (uint) { require(index < elements); require(value < range); index *= bits; return (va & ~(max << index)) | (ev << index); }}get()函式只是根據index引數從值陣列中返回適當的值。set()函式將刪除現有值,然後根據index引數將給定值設定到返回值裡。可以推斷出,只需複製上面給出的uint8a32庫程式碼,然後更改bits和elements常量,即可用於其他uintXaY值陣列型別。Solidity庫合約中無法儲存變數[9]。測試一把讓我們測試一下上面的示例庫程式碼:import "uint8a32.sol";contract TestUint8a32 { using uint8a32 for uint; function test1() public { uint va; va = va.set(0, 0x12); require(va.get(0) == 0x12, "va[0] not 0x12"); va = va.set(1, 0x34); require(va.get(1) == 0x34, "va[1] not 0x34"); va = va.set(31, 0xF7); require(va.get(31) == 0xF7, "va[31] not 0xF7"); }}透過編譯器的using for 指令,因此可以在變數上直接使用. 語法來呼叫set()函式。但是在你的智慧合約需要多種不同的值陣列型別的情況下,由於名稱空間衝突(或者需要每種型別使用各自特定名稱的函式),這需要使用顯式庫名點表示法來訪問函式:import "uint8a32.sol";import "uint16a16.sol";contract MyContract { uint users; // uint8a32 uint roles; // uint16a16 ... function setUser(uint n, uint user) private { // 想實現的是: users = users.set(n, user); users = uint8a32.set(users, n, user); } function setRole(uint n, uint role) private { // 想實現的是: roles = roles.set(n, role); roles = uint16a16.set(roles, n, role); } ...}還需要小心在正確的變數上使用正確的值陣列型別。這是相同的程式碼,但為了闡述該問題,變數名稱包含了資料型別:import "uint8a32.sol";import "uint16a16.sol";contract MyContract { uint users_u8a32; uint roles_u16a16; ... function setUser(uint n, uint user) private { users_u8a32 = uint8a32.set(users_u8a32, n, user); } function setRole(uint n, uint role) private { roles_u16a16 = uint16a16.set(roles_u16a16, n, role); } ...}避免賦值如果我們提供一個使用1個元素的陣列的函式,則實際上有可能避免使用set()函式的返回值賦值。但是,由於此技術使用更多的記憶體,程式碼和複雜性,因此抵消了使用值陣列的可能優勢。Gas 消耗對比編寫了庫和合約後,我們使用在此文[10]中介紹的技術測量了gas消耗。結果如下:bytes32 值陣列
在記憶體和儲存上,bytes32的get和set的Gas消耗32個變數
不用奇怪,在記憶體中gas消耗可以忽略不計,而儲存中,gas消耗是巨大的,尤其是第一次用非零值(大藍色塊)寫入儲存位置時。隨後使用該儲存位置消耗的gas要少得多。
uint8a32 值陣列
在這裡,我們比較了在EVM記憶體中使用固定長度的uint8 []陣列與uint8a32值陣列的情況:
在uint8/byte記憶體上,gas 消耗對比
令人驚訝的是,uint8a32 值陣列消耗的gas只有固定長度陣列uint8[32] 的一半左右。而uint8[16]和uint8[4]相應的gas消耗更低。這是因為值陣列程式碼必須讀取和寫入值才能設定元素值,而uint8[]只需寫入值。
以下是在EVM儲存中比較gas 消耗:
在存款上,gas 消耗的對比
在這裡,與使用uint8[Y]相比,每個uint8a32 set() 函式消耗的gas迴圈少幾百個。uint8 [32],uint8 [16]和uint8 [4]的gas 消耗量相同,因為它們使用相同數量的EVM儲存空間(一個32位元組的插槽)。
uint1a256 值陣列
在EVM記憶體中,固定長度的bool[]陣列與uint1a256值陣列的gas對比:
bool與1bit 在記憶體的 gas消耗 對比
顯然,bool陣列的gas消耗很顯著
相同的比較在EVM儲存中:
bool與1bit 在儲存中的 gas消耗 對比
bool [256]和bool [64] 使用2個儲存插槽,因此gas 消耗相似。bool [32]和uint1a256僅使用一個儲存插槽。
作為合約和庫的引數
將bool/1bit引數傳遞給合約或庫的gas消耗
不用奇怪,最大的gas消耗是為合約或庫函式提供陣列引數。
使用單個值而不是複製陣列顯然會消耗更少的gas。
其他可能性
如果你發現固定長度的值陣列很有用,那麼你還可以考慮固定長度的多值陣列、動態值陣列、值佇列、值堆疊等。
結論
我已經提供用於寫入Solidity bytes32變數的程式碼,以及用於uintX [Y]值陣列的通用庫程式碼。
也提出瞭如固定長度的多值陣列,動態值陣列,值佇列,值堆疊等其他可能性。
是的,我們可以使用值陣列減少儲存空間和gas消耗。
如果你的Solidity智慧合約使用較小值的小陣列(例如使用者ID,角色等),則使用值陣列可能會消耗更少的gas。
當陣列被複制時,例如智慧合約或庫引數,值陣列將始終消耗少得多的gas。
參考資料
[1]
文件: https://learnblockchain.cn/docs/solidity/types.html#arrays
[2]
以太坊虛擬機器(EVM): https://learnblockchain.cn/2019/04/09/easy-evm
[3]
文件-引用型別: https://learnblockchain.cn/docs/solidity/types.html#reference-types
[4]
值型別: https://learnblockchain.cn/docs/solidity/types.html#value-types
[5]
Solidity 目前的版本: https://learnblockchain.cn/docs/solidity/types.html#index-7
[6]
using for: https://learnblockchain.cn/docs/solidity/contracts.html#using-for
[7]
可用整型: https://learnblockchain.cn/docs/solidity/types.html#integers
[8]
這裡: https://en.wikipedia.org/wiki/Arithmetic_coding
[9]
無法儲存變數: https://solidity.readthedocs.io/en/latest/contracts.html#libraries
[10]
此文: https://medium.com/coinmonks/gas-cost-of-solidity-library-functions-dbe0cedd4678
[11]
Julian Goddard: https://medium.com/@plaxion?source=post_page-----32ca65135d5b----------------------