以太坊儲存型別(memory,storage)及變數儲存詳解
1,資料儲存位置(Data location)概念
1.1 storage, memory, calldata, stack區分
在 Solidity 中,有兩個地方可以儲存變數 :儲存(storage)以及記憶體(memory)。Storage變數是指永久儲存在區塊鏈中的變數。Memory 變數則是臨時的,當外部函式對某合約呼叫完成時,記憶體型變數即被移除。
記憶體(memory)位置還包含2種類型的儲存資料位置,一種是calldata,一種是棧(stack)。
(1) calldata
這是一塊只讀的,且不會永久儲存的位置,用來儲存函式引數。 外部函式的引數(非返回引數)的資料位置被強制指定為 calldata ,效果跟 memory 差不多。
(2) 棧(stack)
另外,EVM是一個基於棧的語言,棧實際是在記憶體(memory)的一個數據結構,每個棧元素佔為256位,棧最大長度為1024。 值型別的區域性變數是儲存在棧上。
不同儲存的消耗(gas消耗)是不一樣的,說明如下:
- storage 會永久儲存合約狀態變數,開銷最大;
- memory 僅儲存臨時變數,函式呼叫之後釋放,開銷很小;
- stack 儲存很小的區域性變數,免費使用,但有數量限制(16個變數);
- calldata的資料包含訊息體的資料,其計算需要增加n*68的GAS費用;
storage 儲存結構是在合約建立的時候就確定好了的,它取決於合約所宣告狀態變數。但是內容可以被(交易)呼叫改變。
Solidity 稱這個為狀態改變,這也是合約級變數稱為狀態變數的原因。也可以更好的理解為什麼狀態變數都是storage儲存。
memory 只能用於函式內部,memory 宣告用來告知EVM在執行時建立一塊(固定大小)記憶體區域給變數使用。
storage 在區塊鏈中是用key/value的形式儲存,而memory則表現為位元組陣列
1.2 棧(stack)的延伸閱讀
EVM是一個基於棧的虛擬機器。這就意味著對於大多數操作都使用棧,而不是暫存器。基於棧的機器往往比較簡單,且易於優化,但其缺點就是比起基於暫存器的機器所需要的opcode更多。
所以EVM有許多特有的操作,大多數都只在棧上使用。比如SWAP和DUP系列操作等,具體請參見EVM文件。現在我們試著編譯如下合約:
pragma solidity ^0.4.13;
contract Something{
function foo(address a1, address a2, address a3, address a4, address a5, address a6){
address a7;
address a8;
address a9;
address a10;
address a11;
address a12;
address a13;
address a14;
address a15;
address a16;
address a17;
}
}
你將看到如下錯誤:
CompilerError: Stack too deep, try removing local variables.
這個錯誤是因為當棧深超過16時發生了溢位。官方的“解決方案”是建議開發者減少變數的使用,並使函式儘量小。當然還有其他幾種變通方法,比如把變數封裝到struct或陣列中,或是採用關鍵字memory(不知道出於何種原因,無法用於普通變數)。既然如此,讓我們試一試這個採用struct的解決方案:
pragma solidity ^0.4.13;
contract Something{
struct meh{
address x;
}
function foo(address a1, address a2, address a3, address a4, address a5, address a6){
address a7;
address a8;
address a9;
address a10;
address a11;
address a12;
address a13;
meh memory a14;
meh memory a15;
meh memory a16;
meh memory a17;
}
}
結果呢?
CompilerError: Stack too deep, try removing local variables.
我們明明採用了memory關鍵字,為什麼還是有問題呢?關鍵在於,雖然這次我們沒有在棧上存放17個256bit整數,但我們試圖存放13個整數和4個256bit記憶體地址。
這當中包含一些Solidity本身的問題,但主要問題還是EVM無法對棧進行隨機訪問。據我所知,其他一些虛擬機器往往採用以下兩種方法之一來解決這個問題:
- 鼓勵使用較小的棧深,但可以很方便地實現棧元素和記憶體或其他儲存(比如.NET中的本地變數)的交換;
- 實現pick或類似的指令用於實現對棧元素的隨機訪問;
然而,在EVM中,棧是唯一免費的存放資料的區域,其他區域都需要支付gas。因此,這相當於鼓勵儘量使用棧,因為其他區域都要收費。正因為如此,我們才會遇到上文所述的基本的語言實現問題。
2,不同資料型別的儲存位置
Solidity 型別分為兩類: 值型別(Value Type) 及 引用型別(Reference Types)。Solidity 提供了幾種基本型別,可以用來組合出複雜型別。
(1)值型別(Value Type)
是指 變數在賦值或傳參時總是進行值拷貝,包含:
- 布林型別(Booleans)
- 整型(Integers)
- 定長浮點型(Fixed Point Numbers)
- 定長位元組陣列(Fixed-size byte arrays)
- 有理數和整型常量(Rational and Integer Literals)
- 字串常量(String literals)
- 十六進位制常量(Hexadecimal literals)
- 列舉(Enums)
- 函式(Function Types)
- 地址(Address)
- 地址常量(Address Literals)
(2)引用型別(Reference Types)
是指賦值時我們可以值傳遞也可以引用即地址傳遞,包括:
- 不定長位元組陣列(bytes)
- 字串(string)
- 陣列(Array)
- 結構體(Struts)
引用型別是一個複雜型別,佔用的空間通常超過256位, 拷貝時開銷很大。
所有的複雜型別,即 陣列 和 結構 型別,都有一個額外屬性:“資料位置”,說明資料是儲存在記憶體(memory ,資料不是永久存在)中還是儲存(storage,永久儲存在區塊鏈中)中。 根據上下文不同,大多數時候資料有預設的位置,但也可以通過在型別名後增加關鍵字( storage )或 (memory) 進行修改。
變數預設儲存位置:
- 函式引數(包含返回的引數)預設是memory;
- 區域性變數(local variables)預設是storage;
- 狀態變數(state variables)預設是storage;
區域性變數:區域性作用域(越過作用域即不可被訪問,等待被回收)的變數,如函式內的變數。
狀態變數:合約內宣告的公共變數
資料位置指定非常重要,因為他們影響著賦值行為。
在memory和storage之間或與狀態變數之間相互賦值,總是會建立一個完全獨立的拷貝。
而將一個storage的狀態變數,賦值給一個storage的區域性變數,是通過引用傳遞。所以對於區域性變數的修改,同時修改關聯的狀態變數。
另一方面,將一個memory的引用型別賦值給另一個memory的引用,不會建立拷貝(即:memory之間是引用傳遞)。
注意:
不能將memory賦值給區域性變數。
對於值型別,總是會進行拷貝。
下面引用一段合約程式碼作說明:
pragma solidity ^0.4.0;
contract C {
uint[] x; // x 的資料儲存位置是 storage
// memoryArray 的資料儲存位置是 memory
function f(uint[] memoryArray) public {
x = memoryArray; // 將整個陣列拷貝到 storage 中,可行
var y = x; // 分配一個指標(其中 y 的資料儲存位置是 storage),可行
y[7]; // 返回第 8 個元素,可行
y.length = 2; // 通過 y 修改 x,可行
delete x; // 清除陣列,同時修改 y,可行
// 下面的就不可行了;需要在 storage 中建立新的未命名的臨時陣列, /
// 但 storage 是“靜態”分配的:
// y = memoryArray;
// 下面這一行也不可行,因為這會“重置”指標,
// 但並沒有可以讓它指向的合適的儲存位置。
// delete y;
g(x); // 呼叫 g 函式,同時移交對 x 的引用
h(x); // 呼叫 h 函式,同時在 memory 中建立一個獨立的臨時拷貝
}
function g(uint[] storage storageArray) internal {}
function h(uint[] memoryArray) public {}
3,變數具體儲存位置舉例
3.1 定位固定大小的值
在這個存模型中,究竟是怎麼樣儲存的呢?對於具有固定大小的已知變數,在記憶體中給予它們保留空間是合理的。Solidity程式語言就是這樣做的。
contract StorageTest {
uint256 a;
uint256[2] b; struct Entry {
uint256 id;
uint256 value;
}
Entry c;
}
在上面的程式碼中:
- a儲存在下標0處。(solidity表示記憶體中儲存位置的術語是“下標(slot)”。)
- b儲存在下標1和2(陣列的每個元素一個)。
- c從插槽3開始並消耗兩個插槽,因為該結構體Entry儲存兩個32位元組的值。
這些下標位置是在編譯時確定的,嚴格基於變量出現在合同程式碼中的順序。
3.2 查詢動態大小的值
使用保留下標的方法適用於儲存固定大小的狀態變數,但不適用於動態陣列和對映(mapping),因為無法知道需要保留多少個槽。
如果您想將計算機RAM或硬碟驅動器作為比喻,您可能會希望有一個“分配”步驟來查詢可用空間,然後執行“釋放”步驟,將該空間放回可用儲存池中。
但是這是不必要的,因為智慧合約儲存是一個天文數字級別的規模。儲存器中有2^256個位置可供選擇,大約是已知可觀察宇宙中的原子數。您可以隨意選擇儲存位置,而不會遇到碰撞。您選擇的位置相隔太遠以至於您可以在每個位置儲存儘可能多的資料,而無需進入下一個位置。
當然,隨機選擇地點不會很有幫助,因為您無法再次查詢資料。Solidity改為使用雜湊函式來統一併可重複計算動態大小值的位置。
3.3 動態大小的陣列
動態陣列需要一個地方來儲存它的大小以及它的元素。
contract StorageTest {
uint256 a; // slot 0
uint256[2] b; // slots 1-2
struct Entry {
uint256 id;
uint256 value;
}
Entry c; // slots 3-4
Entry[] d;
}
在上面的程式碼中,動態大小的陣列d存在下標5的位置,但是儲存的唯一資料是陣列的大小。陣列d中的值從下標的雜湊值hash(5)開始連續儲存。
下面的Solidity函式計算動態陣列元素的位置:
function arrLocation(uint256 slot, uint256 index, uint256 elementSize)
public
pure
returns (uint256)
{
return uint256(keccak256(slot)) + (index * elementSize);
}
3.4 對映(Mappings)
一個對映mapping需要有效的方法來找到與給定的鍵相對應的位置。計算鍵的雜湊值是一個好的開始,但必須注意確保不同的mappings產生不同的位置。
contract StorageTest {
uint256 a; // slot 0
uint256[2] b; // slots 1-2
struct Entry {
uint256 id;
uint256 value;
}
Entry c; // slots 3-4
Entry[] d; // slot 5 for length, keccak256(5)+ for data
mapping(uint256 => uint256) e;
mapping(uint256 => uint256) f;
}
在上面的程式碼中,e的“位置” 是下標6,f的位置是下標7,但實際上沒有任何內容儲存在這些位置。(不知道多長需要儲存,並且獨立的值需要位於其他地方。)
要在對映中查詢特定值的位置,鍵和對映儲存的下標會一起進行雜湊運算。
以下Solidity函式計算值的位置:
function mapLocation(uint256 slot, uint256 key) public pure returns (uint256) {
return uint256(keccak256(key, slot));
}
請注意,當keccak256函式有多個引數時,在雜湊運算之前先將這些引數連線在一起。由於下標和鍵都是雜湊函式的輸入,因此不同mappings之間不會發生衝突。
3.5 複雜型別的組合
動態大小的陣列和mappings可以遞迴地巢狀在一起。當發生這種情況時,通過遞迴地應用上面定義的計算來找到值的位置。這聽起來比它更復雜。
contract StorageTest {
uint256 a; // slot 0
uint256[2] b; // slots 1-2
struct Entry {
uint256 id;
uint256 value;
}
Entry c; // slots 3-4
Entry[] d; // slot 5 for length, keccak256(5)+ for data
mapping(uint256 => uint256) e; // slot 6, data at h(k . 6)
mapping(uint256 => uint256) f; // slot 7, data at h(k . 7)
mapping(uint256 => uint256[]) g; // slot 8
mapping(uint256 => uint256)[] h; // slot 9
}
要找到這些複雜型別中的專案,我們可以使用上面定義的函式。要找到g123:
// first find arr = g[123]
arrLoc = mapLocation(8, 123); // g is at slot 8
// then find arr[0]
itemLoc = arrLocation(arrLoc, 0, 1);
要找到h2:
// first find map = h[2]
mapLoc = arrLocation(9, 2, 1); // h is at slot 9
// then find map[456]
itemLoc = mapLocation(mapLoc, 456);
3.6 總結
- 每個智慧合約都以2^256個32位元組值的陣列形式儲存,全部初始化為零。
- 零沒有明確儲存,因此將值設定為零會回收該儲存。
- Solidity中,確定佔記憶體大小的值從第0號下標開始放。
- Solidity利用儲存的稀疏性和雜湊輸出的均勻分佈來安全地定位動態大小的值。
下表顯示瞭如何計算不同型別的儲存位置。“下標”是指在編譯時遇到狀態變數時的下一個可用下標,而點表示二進位制串聯: