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利用存儲的稀疏性和散列輸出的均勻分布來安全地定位動態大小的值。
下表顯示了如何計算不同類型的存儲位置。“下標”是指在編譯時遇到狀態變量時的下一個可用下標,而點表示二進制串聯: