合約是一座你永久租用的資料庫
上一篇你寫了一個帶有幾個狀態變數的合約——一個數字、一個位址。真實的合約要塑造的資料豐富得多:一個代幣為每位持有者記下的餘額、一個 NFT 為每個 token id 記下的擁有者、一個 DAO 的提案清單。把它想成一座住在 智慧合約 裡的小型資料庫——只不過這座資料庫被複製到數千個節點上、你寫下的每個位元組都用 gas 付費,而且存進去的東西會永遠留存。正是這份經濟重量,讓 Solidity 的型別系統比你熟悉的語言更嚴格:它要你精確說出一個值有多大、又住在哪裡。
這篇分三層講這套型別系統。先是數值型別——那些一碰就被整份複製的 256 位元基本元件。接著是參考型別(陣列、結構、字串)以及 Solidity 強加給你的規則:每個參考都必須標記為 `storage`、`memory` 或 `calldata`。然後是鏈上資料的主力——映射。最後我們把一個真實的登錄合約一個 slot、一個 slot 地排進 storage,讓你能清楚看見——並付更少代價給——你親手蓋出來的東西。
數值型別:256 位元的基本元件
EVM 的原生字組寬 256 位元,所以 Solidity 的首選數字型別是 `uint256`(無號,0 到 2²⁵⁶−1)。你可以用 8 位元為單位要更窄的尺寸——`uint8`、`uint16`、……`uint256`——也可以用 `int` 要有號版本。數值型別在你指派或把它傳進函式時都會被整份複製,原本的值絕不會被動到。其他你會不停用到的數值型別包括 `address`(20 位元組的帳戶)、`bool`、定長位元組塊 `bytes1`…`bytes32`(雜湊天生就適合用 `bytes32` 裝),以及 `enum`——編譯器在底層會把它存成一個小整數。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract ValueTypes {
uint256 public count; // unsigned, 0 .. 2^256-1
int256 public temperature; // signed
uint8 public smallFlag; // 0 .. 255
address public owner; // a 20-byte account address
bool public paused; // true / false
bytes32 public root; // a fixed 32-byte blob, e.g. a hash
enum Status { Pending, Active, Closed }
Status public status; // stored as a uint8 under the hood
function tick() external {
count += 1; // since 0.8, this REVERTS on overflow
}
}Solidity 0.8 永遠改變了一件事:算術運算預設帶溢位檢查。在 0.8 之前,`count += 1` 一旦越過上限會無聲地繞回 0(整數溢位)——這個漏洞曾掏空真實的合約。如今它會直接 revert 整筆交易。一個留待後面講的微妙點:把型別宣告成 `uint8` 而不是 `uint256`,本身並不會省 gas,因為一個孤立的變數仍佔滿一整個 32 位元組的 slot。唯有當好幾個小欄位一起打包進同一個 slot 時,節省才會出現——這正是結構登場之處。
參考型別與「資料位置」規則
陣列、結構、`bytes` 與 `string` 都是參考型別。它們大小可以任意,所以不會待在 EVM 那個小小的堆疊上——它們住在某處,而你傳遞的是指向那個地方的參考。Solidity 拒絕替你猜那是哪裡:每個參考型別的變數都必須標上一個資料位置。位置恰好只有三種,而選對它,就完成了寫出正確又省錢 Solidity 的一半。
- storage——合約的永久狀態,寫進在交易之間留存的 EVM storage。動到它的代價最昂貴。一個 `storage` 區域變數是指向既有狀態的別名(指標),而不是複本。
- memory——暫時的工作空間,在一次呼叫期間重新配置,函式回傳時就被清乾淨。比 storage 便宜;用它來放工作用的複本,以及你逐步組好再回傳的值。
- calldata——存放一筆交易輸入的唯讀區域。它不能被修改、不會被複製,是三者中最便宜的。把它用在 `external` 函式的陣列與字串參數上。
struct Point { uint256 x; uint256 y; }
contract Locations {
Point[] public points; // lives in storage (persistent)
// calldata: read-only input, never copied, cheapest
function sum(uint256[] calldata xs) external pure returns (uint256 s) {
for (uint256 i; i < xs.length; ++i) s += xs[i];
}
// memory: a temporary copy, gone when the call ends
function makePoint(uint256 x, uint256 y) external pure returns (Point memory) {
return Point({x: x, y: y});
}
// storage reference: a POINTER into points[i]; writes persist
function bumpX(uint256 i) external {
Point storage p = points[i]; // alias, not a copy
p.x += 1; // mutates storage directly
}
}新手最常見的單一錯誤就藏在這個區別裡。`Point storage p = points[i]` 讓 `p` 成為一個別名——透過它寫入會改到被存起來的那個點。但 `Point memory p = points[i]` 會先把結構從 storage 複製進 memory;你做的任何修改都只動到那份用完即丟的複本,函式結束時無聲地被丟棄。編譯器不會攔你;鏈只是安靜地什麼也沒做。
// BUG: `Point memory p` copies the struct; the write is thrown away.
function brokenBumpX(uint256 i) external {
Point memory p = points[i]; // COPY of storage into memory
p.x += 1; // mutates the copy only
} // storage unchanged -> silent no-op映射:鏈上的雜湊表
映射是以太坊上用得最多的單一資料結構。`mapping(KeyType => ValueType)` 表現得像雜湊表——`balances[alice]` 是讀取、`balances[alice] = 10` 是寫入——但有三條會讓新手吃驚的規則。第一,每一個可能的鍵都已經存在,在你設定它之前都回傳該型別的零值。第二,你無法走訪一個映射,也問不到它的長度;它根本不知道哪些鍵被用過。第三,它只能住在 `storage`,不能被整份回傳,也不能放在 `memory` 裡。
// the classic balance ledger
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value; // a missing key reads as 0, so += just works
}
// nested mapping: owner => spender => amount (the ERC-20 allowance pattern)
mapping(address => mapping(address => uint256)) public allowance;為什麼不能走訪?因為映射根本不存它的鍵。如果映射宣告在 slot `p`,鍵 `k` 的值就住在 storage slot `keccak256(abi.encode(k, p))`——一個確定但分散的位置。根本沒有一張鍵的清單可走。這也是為什麼宣告一個映射不管它將容納多少鍵都不花成本:一開始什麼都沒配置;唯有你第一次為某個鍵存入非零值時,才會寫進一個 storage slot。
結構:把多個欄位捆成一筆紀錄
結構把相關的欄位捆成一個有名字的紀錄型別。把它和 `mapping(address => Member)` 搭起來,你就有了一個按使用者分的儲存——一位成員的名字、等級與加入時間全都掛在他的位址底下。這種「結構+映射」的形態,是極大一部分真實合約的骨幹。底下這個小型登錄合約把這篇講的東西全兜在一起。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract MemberRegistry {
struct Member {
string name; // dynamic -> gets its own slot(s)
uint64 joinedAt; // unix time; packs with `tier` and `active`
uint32 tier;
bool active;
}
mapping(address => Member) private members;
uint256 public total;
function register(string calldata name, uint32 tier) external {
Member storage m = members[msg.sender]; // storage reference
require(m.joinedAt == 0, "already registered"); // sentinel check
m.name = name;
m.joinedAt = uint64(block.timestamp);
m.tier = tier;
m.active = true;
total += 1;
}
function deactivate() external {
members[msg.sender].active = false; // one-field write, no copy
}
function memberOf(address who)
external
view
returns (string memory name, uint64 joinedAt, uint32 tier, bool active)
{
Member storage m = members[who];
return (m.name, m.joinedAt, m.tier, m.active);
}
}慢慢讀。`register` 把 `name` 當 `calldata`(最便宜的輸入)接進來,抓一個 `storage` 參考 `m`,並用 `m.joinedAt == 0` 當哨兵,讓任何位址都無法重複登錄。`deactivate` 直接寫單一欄位——`members[msg.sender].active = false`——完全不複製整筆紀錄。`memberOf` 把欄位以 tuple 形式回傳;注意它能回傳結構的內容,但函式永遠無法回傳一個本身含有映射的結構,因為映射沒辦法被複製進 memory。
它在 storage 中如何排列——以及打包為何省錢
一個合約的 storage 在概念上是 2²⁵⁶ 個各 32 位元組的 slot,全都從零開始。編譯器以確定的方式分配版面:普通狀態變數依宣告順序拿到 slot 0、1、2……,而幾個塞得下的小欄位會被並排打包進同一個 slot。動態的東西——映射與動態陣列——只在它們宣告的 slot 放一個佔位符,把真正的資料散到雜湊出來的位置,因此絕不會和那些固定變數相撞。
回頭看 `Member` 結構。它的 `name` 是動態的 `string`,所以自己佔一個 slot(裝著長度,外加一個指向字元資料的指標)。接下來三個欄位——`uint64 joinedAt`(8 位元組)、`uint32 tier`(4 位元組)、`bool active`(1 位元組)——合計 13 位元組,輕鬆塞進 32 以內,於是編譯器把這三個全打包進同一個 32 位元組的 slot。這不是裝飾性的細節:它意味著 `joinedAt`/`tier`/`active` 這整組可以用一次 storage 寫入完成,而不是三次。
為什麼要為了一次寫入這麼斤斤計較?因為寫 storage 遙遙領先地是合約所做最昂貴的事。粗略地說:把一個 slot 從零設成非零約花 20,000 gas(外加約 2,100 的首次接觸附加費),更動一個已是非零的 slot 約 5,000 gas,而僅僅讀取一個 slot 在冷狀態約 2,100 gas、在這筆交易中已碰過則約 100 gas。相比之下運算幾乎免費——一個 `ADD` 才 3 gas。所以 Solidity 的 gas 最佳化 壓倒性地關乎少碰幾個 storage slot,而欄位打包是達成它最便宜的辦法。
常見陷阱,與一個能帶著走的心智模型
這些型別如今是你在鏈上塑造任何東西的詞彙。在往下走之前,把這些連老手都會中招的陷阱內化吧——它們幾乎全源自一件事:忘了複製一個值和參考一份狀態之間的差別。
- 區域變數的 memory 對 storage。`Foo memory f = items[i]` 是複製,你的寫入會消失;`Foo storage f = items[i]` 是別名,你的寫入會留下。當你不確定一個改動到底有沒有存進去,這是第一個該檢查的地方。
- 預設為零的歧義。映射裡的值 0 可能代表「未設定」,也可能代表「真的是零」。當這個區別要緊時,用一個明確的哨兵欄位(像 `joinedAt`)或另一個 `mapping(... => bool) exists`。
- 映射不可列舉。沒有長度、不能迴圈、沒有 `keys()`。如果你需要清單,就自己維護一個並行的 `address[]`——並記得那個陣列可能無上限地增長,讓遍歷它的函式耗盡 gas。
- `delete` 是歸零,不是移除。`delete members[addr]` 把那筆結構的欄位重設為零值;它無法真的從映射中抹掉一個鍵,也清不掉巢狀在結構裡的映射。
緊握一個心智模型:數值型別是你隨身帶著的東西,參考型別是你指向的一個地方,而資料位置標記則說明那個地方是永久的(storage)、暫存的(`memory`),還是傳進來的訊息(`calldata`)。掌握了資料塑造,下一篇就加上護欄——Solidity 的修飾器、`require`、自訂錯誤與事件——它們決定誰能更動這份狀態,以及外界如何得知這件事。