3MIKAN
仮想通貨直コン

Solidityのdeleteでmappingは消えない?配列再利用とキー削除を確認する

構造体や配列をdeleteしてもmappingの値が残る理由を、再割り当てとpopの例で解説します。既知キーの削除、世代による分離、初期値と未登録の違いを確認します。

3MIKANのキャラクターが記録の残った引き出しを開き、新しい引き出しと区別する場面

Solidityで配列をdeleteし、長さが0になったことを確認した。それなのに、同じ位置へ要素を作り直すと以前の値が読める。この現象は、配列の要素にmappingが含まれると起こり得ます。

この記事は、登録情報、設定、期間ごとの記録を配列とマッピングで管理する開発者向けです。配列からアクセスできないこと、現在の値を消すこと、過去の記録がなくなることを分け、再利用してよい領域かを判断できるようにします。

整数だけの例を隔離したEVMで扱います。実在サービスへの操作、送金、秘密鍵、外部RPCは不要です。

deleteは、何でも完全消去する命令ではない

Solidityのdeleteの規則では、通常の整数は初期値の0へ戻ります。動的配列なら長さが0になり、配列の要素一つに対するdeleteは配列長を変えません。

ただし、mappingは書き込んだキーの一覧を内部に保持していません。そのため、構造体や配列を削除するときに、含まれるマッピングのすべてのキーをたどって消すことはできません。

「構造体を削除すれば、その内側まで無条件に空になる」と考えないようにします。SolidityのClearing Mappingsでも、配列の削除やpopがマッピングの要素を消さない点が説明されています。

整数のラベルと、キーごとの値を持つ例

次のMappingDeletionは、一つの行に整数のlabelと、キーごとの整数値を持たせています。誰でも呼べる学習用コードであり、実運用の権限管理やデータ管理を実装したものではありません。

後半のGenerationLedgerは、消去する代わりに世代を分ける例です。まずは前半だけを見てください。

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

// Teaching example: no authorization or production lifecycle policy.
contract MappingDeletion {
    struct Row {
        uint256 label;
        mapping(uint256 => uint256) values;
    }
    Row[] private rows;

    function allocate(uint256 label) external {
        rows.push();
        rows[rows.length - 1].label = label;
    }

    function write(uint256 index, uint256 key, uint256 value) external {
        rows[index].values[key] = value;
    }

    function read(uint256 index, uint256 key)
        external view returns (uint256 label, uint256 value)
    {
        return (rows[index].label, rows[index].values[key]);
    }

    function deleteRow(uint256 index) external { delete rows[index]; }
    function clearArray() external { delete rows; }
    function popLast() external { rows.pop(); }
    function deleteKey(uint256 index, uint256 key) external {
        delete rows[index].values[key];
    }
    function length() external view returns (uint256) { return rows.length; }
}

// Demonstrates logical separation, not physical erasure or access control.
contract GenerationLedger {
    uint256 public generation;
    mapping(uint256 => mapping(uint256 => uint256)) private values;

    function write(uint256 key, uint256 value) external {
        values[generation][key] = value;
    }
    function current(uint256 key) external view returns (uint256) {
        return values[generation][key];
    }
    function historical(uint256 oldGeneration, uint256 key) external view returns (uint256) {
        return values[oldGeneration][key];
    }
    function nextGeneration() external { ++generation; }
}

新しいコントラクトでallocate(1)write(0, 7, 99)を実行すると、0番目の行のキー7に99が入ります。read(0, 7)の返り値は(1, 99)です。

そこから、削除する対象ごとの違いを確認します。

構造体をdeleteしても、キー7の99は残る

deleteRow(0)を実行すると、整数のlabelは0へ戻ります。しかし、read(0, 7)(0, 99)を返します。マッピングの値は変わっていません。

このとき配列長は1のままです。要素一つを初期化したのであって、後ろの行を前へ詰めたり、行番号を取り除いたりする操作ではありません。

ラベルが0だから未登録だと判断する設計では、マッピング側の値まで未登録とみなしてよいかを別に検討する必要があります。

配列長0は、マッピング消去の証明にならない

次は、新しく用意した同じ初期状態からclearArray()を呼びます。配列長は0になるため、直後のread(0, 7)は添字の範囲外で失敗します。

しかし、続いてallocate(2)を呼ぶと、0番目の位置が再び使われます。そこでread(0, 7)を実行すると、返り値は(2, 99)です。キー7へ書き直していないのに、以前の99が読めます。

allocate(1) → write(0, 7, 99) → clearArray()
配列長は0。0番目の読み取りは範囲外。

allocate(2) → read(0, 7)
結果は (2, 99)。新しいラベルと以前の値が並ぶ。

同じ初期状態からpopLast()で末尾を除き、allocate(3)で作り直す場合も、結果は(3, 99)です。popへ置き換えるだけでは、この例のマッピング値は消えません。

キーが分かっているなら、その値を削除する

キー7を消したいなら、deleteKey(0, 7)で対象を指定できます。値型が整数なので、削除後の読み取りは0になります。

同時にキー8へ55を書いておけば、キー7の削除後もキー8は55のままです。特定のキーの削除と、マッピング全体の削除を区別できます。

ただし、マッピングの値がさらにマッピングを含む構造体なら、その内側にも同じ注意が必要です。また、整数の0は未登録の初期値でも、明示的に保存した値でもあり得ます。登録の有無が必要な設計なら、値だけでなく別の存在フラグなどを用意します。

キーを一覧として別管理し、順に消す方式も考えられますが、一度に処理する件数を無制限に増やさないでください。処理がガスの上限に収まらない可能性は、ループに関する公式の注意で確認できます。

世代を変える方法は、消去ではなく別の領域の参照

後半のGenerationLedgerは、values[generation][key]という二段階のキーを使います。世代0でキー7へ99を保存してから世代1へ進むと、current(7)は0です。

ただし、historical(0, 7)は99を返します。古い値を消したのではなく、現在の読み取り先を変えただけだからです。世代1へ12を書いた後も、世代0の99は残ります。

この方法を使う場合は、読み取り・書き込み・権限判定のすべてで、どの世代を見るかを揃えます。世代を巻き戻したり、古い世代を再利用したりすれば、隔離したつもりの値へ再び到達します。

例の世代番号は通常の検査付き加算で増やしています。ここへ安易にuncheckedを付けたり、誰でも世代を切り替えられる学習用の関数を実運用へそのまま持ち込んだりしないでください。

検証は「消した直後」だけで終えない

削除を検証するときは、初期状態から書き込み、削除、その後の再割り当てまで確認します。今回の違いをまとめると、次のようになります。

操作 配列長 キー7の99
deleteRow(0) 1のまま そのまま読める
clearArray()直後 0 配列の添字が範囲外で読めない
その後のallocate(2) 1 再び読める
popLast()後にallocate(3) 1 再び読める
deleteKey(0, 7) 1のまま 整数の初期値0へ戻る

FoundryではMappingDeletionを作り直して各ケースを分離し、返り値と配列長、範囲外のPanic(0x32)を確かめます。コンパイラーをSolidity 0.8.36、EVM設定をPragueへ固定すれば、同じ条件で比較できます。

Foundryを導入したうえで、空の作業フォルダーにsrctestという名前のフォルダーを作ります。先ほどのMappingDeletion.solsrcの中へ、次のコードはtestの中にMappingDeletion.t.solとして保存してください。削除後の再利用と既知キーの削除を確認できます。

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

contract MappingDeletionSmokeTest {
    function testReuseAfterDelete() public {
        MappingDeletion example = new MappingDeletion();
        example.allocate(1);
        example.write(0, 7, 99);
        example.clearArray();
        assert(example.length() == 0);
        example.allocate(2);
        (uint256 label, uint256 value) = example.read(0, 7);
        assert(label == 2 && value == 99);
        example.deleteKey(0, 7);
        (, value) = example.read(0, 7);
        assert(value == 0);
    }
}
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上で返り値と失敗条件を検証しています。公開チェーンへの接続、実ウォレットの操作、送信はしていません。表示されたガス値の優劣や実運用の安全性を比較する記事ではありません。

まとめ

削除処理を読むときは、配列長、構造体の通常メンバー、マッピングの既知キー、再利用した領域を別々に確認します。長さが0になったことだけでは、内側の値の消去を確認できません。

既知キーの削除と、世代による論理的な分離も別の操作です。そして、現在の値を0へ戻す処理を、公開チェーンの過去の記録まで消す機能として説明しないでください。

より大きいコントラクトで操作順による問題を探す場合は、状態保持型ファジングの比較で、単一の呼び出しでは見つからない状態変化をどのように検証するかを確認できます。

確認した一次情報