3MIKAN
仮想通貨直コン

Solidityのmemory・storage・calldata:配列の代入はコピーか参照か

memoryへ代入した配列が一緒に変わる理由を、保存先と代入先から整理します。独立したコピー、状態の書き換え、calldataの変更禁止を小さなSolidityコードで確かめます。

3MIKANのキャラクターがコピー元と独立したトレイを見比べ、一方のブロックだけを動かす場面

Solidityで配列を別の変数へ代入してから書き換えたら、元の配列まで変わった。反対に、変更したはずの値がコントラクトへ保存されなかった。この二つは、どちらもデータの置き場所と代入の意味を取り違えると起きます。

この記事は、memorystoragecalldataを「一時的か永続的か」だけで覚えている開発者向けです。小さな整数配列を使い、別の名前で同じデータを指す場合と、独立したコピーを作る場合を見分けます。

扱うのは言語の挙動だけです。ウォレット接続、アカウント登録、外部RPC、送金は必要ありません。

保存期間と、代入の意味を分ける

storageはコントラクトの状態を保存する場所、memoryは一回の外部呼び出しの実行中に使う一時領域、calldataは変更できない入力領域です。ただし、置き場所の説明だけでは、代入後に元の値が変わるかを判断できません。

たとえば、机の書類に別の付箋を貼ることと、書類をコピーして別の机へ置くことは違います。変数名が二つあるからといって、データ本体も二つあるとは限りません。

Solidityの代入規則に沿って、まず次の区別を使います。

代入するもの 代入後の関係 書き換えたとき
memoryの配列を別のmemory変数へ 同じ配列への参照 両方の名前から変更が見える
状態配列をローカルのstorage変数へ 同じ状態配列への参照 コントラクトの状態が変わる
状態配列をmemory変数へ 独立したコピー コピーの編集だけでは状態は変わらない
memory配列を状態変数へ 状態側へコピー 元の一時配列を後で編集しても状態には反映されない
calldataの配列をmemory変数へ 編集できるコピー 入力領域は変わらない

この表は配列などの参照型についてのものです。uint256boolを変数へ代入する値型の話と混ぜないでください。また、「storageへの代入はすべて参照」とも覚えないようにします。ローカル変数の参照を作るのか、状態変数そのものへ値を代入するのかが違います。

一つのコントラクトで六つの違いを見る

次のコードは、最初の状態配列を[10, 20]にします。各関数を新しく作ったコントラクトで試すと、前の例による状態変更を混ぜずに比較できます。

DataLocations.solという名前で保存してください。

// SPDX-License-Identifier: MIT
pragma solidity 0.8.36;

contract DataLocations {
    uint256[] private stored;

    constructor() {
        stored.push(10);
        stored.push(20);
    }

    function memoryAlias() external pure returns (uint256, uint256) {
        uint256[] memory a = new uint256[](1);
        a[0] = 10;
        uint256[] memory b = a;
        b[0] = 99;
        return (a[0], b[0]);
    }

    function independentMemory() external pure returns (uint256, uint256) {
        uint256[] memory a = new uint256[](1);
        a[0] = 10;
        uint256[] memory b = new uint256[](a.length);
        for (uint256 i; i < a.length; ++i) b[i] = a[i];
        b[0] = 99;
        return (a[0], b[0]);
    }

    function storageToMemory() external view returns (uint256, uint256) {
        uint256[] memory copy = stored;
        copy[0] = 99;
        return (stored[0], copy[0]);
    }

    function storageAlias() external returns (uint256) {
        uint256[] storage ref = stored;
        ref[0] = 99;
        return stored[0];
    }

    function memoryToStorage() external returns (uint256, uint256) {
        uint256[] memory input = new uint256[](1);
        input[0] = 7;
        stored = input;
        input[0] = 99;
        return (stored[0], input[0]);
    }

    function calldataToMemory(uint256[] calldata input)
        external pure returns (uint256, uint256)
    {
        require(input.length > 0, "empty input");
        uint256[] memory copy = input;
        copy[0] = 99;
        return (input[0], copy[0]);
    }

    function first() external view returns (uint256) {
        return stored[0];
    }
}

最初に見るのはmemoryAliasです。b = aは新しい配列を作らないため、b[0] = 99の後はa[0]も99です。返り値は(99, 99)になります。

independentMemoryでは、先にnewで別の配列を確保し、整数を一つずつ移しています。そのため返り値は(10, 99)で、元の配列は変わりません。

このコピーはuint256[]に対する例です。要素がさらに参照型になっている入れ子の配列へ、同じ一重のループをそのまま「深いコピー」として適用しないでください。各階層で独立させたいデータを確保する必要があります。

memoryの編集は、状態への保存とは違う

storageToMemoryは、状態配列を一時領域へコピーしてから編集します。返り値は(10, 99)であり、続けてfirst()を読んでも状態側は10です。

一方、storageAliasが作るrefは、状態配列につけた別名です。そこへ99を書けば、続くfirst()も99になります。ローカル変数として宣言していても、参照先がローカルな一時データになるわけではありません。

memoryToStorageは別の方向です。まず[7]を状態変数へ代入し、その後で元の一時配列を99に変えます。返り値は(7, 99)となり、保存した7は後からの変更に追従しません。

状態更新のレビューでは、変数名の印象よりも、storage参照への書き込みがどこにあるかを追うと判断しやすくなります。

calldataは「変更する前にコピーする」

calldataToMemory([10])(10, 99)を返します。読み取り専用の入力から、編集できる一時配列を作ったためです。

コピーを省いてinput[0] = 99と書いたコードは、Solidity 0.8.36ではコンパイル時に拒否されます。実行時に失敗したり、状態へ保存されなかったりする話ではなく、そもそも有効なコードになりません。

空配列は、別の条件です。この例では添字0を読む前にrequireで拒否します。「どの領域か」と「その添字が存在するか」は別々に検査します。

小さなテストで判断を固定する

Foundryを導入したうえで、空の作業フォルダーにsrctestという名前のフォルダーを作ります。上のDataLocations.solsrcの中へ、次のコードはtestの中にDataLocations.t.solとして保存してください。外部ライブラリーは使いません。

// SPDX-License-Identifier: MIT
pragma solidity 0.8.36;
import {DataLocations} from "../src/DataLocations.sol";

contract DataLocationsSmokeTest {
    function testCopyAndReference() public {
        DataLocations example = new DataLocations();
        (uint256 a, uint256 b) = example.memoryAlias();
        assert(a == 99 && b == 99);
        (a, b) = example.storageToMemory();
        assert(a == 10 && b == 99);
        assert(example.storageAlias() == 99);
        assert(example.first() == 99);
    }
}
forge test --use 0.8.36 --evm-version prague --optimize --optimizer-runs 200

コンパイラーの初回取得にはネットワークを使う場合がありますが、例の実行にチェーンへの接続は不要です。導入済みのコンパイラーを使う場合は、--useへ実行ファイルのパスを渡して--offlineを指定します。

確認した環境と範囲

項目 使用した条件
確認日 2026年9月5日
コンパイラー Solidity 0.8.36、ビルド識別子8a079791
テスト実行 Foundry 1.8.0
EVM設定 Prague
最適化 有効、200 runs
OS Ubuntu 24.04.4、x86_64

例のコントラクトはコンパイルし、隔離したEVM上で返り値と失敗条件を検証しています。公開チェーンへの接続、実ウォレットの操作、送信はしていません。表示されたガス値の優劣や実運用の安全性を比較する記事ではありません。

まとめ

代入を読んだら、元と先のデータ領域を確認し、さらに代入先がローカルの参照変数なのか、状態変数なのかを見ます。

同じ配列へ別名をつけたのか、独立した配列を作ったのか、状態へ書き戻したのか。この三つを区別すれば、「元まで変わった」と「保存されなかった」を同じ原因として扱わずに済みます。

外部から渡された配列がどのバイト列へ符号化されるかは、ABIと入力データの読み方で確認できます。入力の符号化と、実行中のコピー・参照は別の層として整理してください。

確認した一次情報