mapping とは

mapping は、Solidityの最も重要なデータ構造のひとつです。キーと値のペア(Key-Value)を格納するハッシュテーブルで、JavaScriptの Map やPythonの dict に近い概念です。

// 基本構文
mapping(キーの型 => 値の型) アクセス修飾子 変数名;

// 例
mapping(address => uint256) public balances;
mapping(uint256 => string) public names;
mapping(address => bool) public isRegistered;

mappingの特徴は以下のとおりです。

  • O(1)のアクセス: キーによる読み書きが定数時間で行える
  • デフォルト値: 存在しないキーにアクセスすると型のデフォルト値が返る(uint256なら0、boolならfalse、addressなら0x0)
  • 列挙不可: キーの一覧を取得する方法がない(自分で管理する必要がある)
  • ストレージ専用: memory変数やローカル変数としては使えない

基本的なmappingの使い方

アドレスごとのポイント管理をする簡単なコントラクトで、mappingの基本操作を学びましょう。

// contracts/PointSystem.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract PointSystem {
    // アドレス => ポイント残高
    mapping(address => uint256) public points;

    // 登録済みアドレスの一覧(mappingは列挙できないため配列で管理)
    address[] public registeredUsers;
    mapping(address => bool) public isRegistered;

    event PointsAdded(
        address indexed user,
        uint256 amount,
        uint256 newBalance
    );
    event PointsUsed(
        address indexed user,
        uint256 amount,
        uint256 newBalance
    );

    /// @notice ポイントを付与する
    function addPoints(address user, uint256 amount) public {
        if (!isRegistered[user]) {
            isRegistered[user] = true;
            registeredUsers.push(user);
        }

        points[user] += amount;

        emit PointsAdded(user, amount, points[user]);
    }

    /// @notice ポイントを使用する
    function usePoints(uint256 amount) public {
        require(points[msg.sender] >= amount, "Insufficient points");

        points[msg.sender] -= amount;

        emit PointsUsed(msg.sender, amount, points[msg.sender]);
    }

    /// @notice ポイント残高を確認する
    function getBalance(address user) public view returns (uint256) {
        return points[user];
    }

    /// @notice 登録ユーザー数を取得する
    function getUserCount() public view returns (uint256) {
        return registeredUsers.length;
    }
}

テスト

// test/PointSystem.test.ts
import { expect } from "chai";
import { ethers } from "hardhat";

describe("PointSystem", function () {
  async function deployFixture() {
    const [owner, user1, user2] = await ethers.getSigners();
    const PointSystem = await ethers.getContractFactory("PointSystem");
    const points = await PointSystem.deploy();
    return { points, owner, user1, user2 };
  }

  it("ポイントを付与して残高を確認できる", async function () {
    const { points, user1 } = await deployFixture();

    await points.addPoints(user1.address, 100);
    expect(await points.getBalance(user1.address)).to.equal(100);

    // さらにポイントを追加
    await points.addPoints(user1.address, 50);
    expect(await points.getBalance(user1.address)).to.equal(150);
  });

  it("ポイントを使用できる", async function () {
    const { points, user1 } = await deployFixture();

    await points.addPoints(user1.address, 100);
    await points.connect(user1).usePoints(30);

    expect(await points.getBalance(user1.address)).to.equal(70);
  });

  it("残高不足の場合はエラーになる", async function () {
    const { points, user1 } = await deployFixture();

    await points.addPoints(user1.address, 50);

    await expect(
      points.connect(user1).usePoints(100)
    ).to.be.revertedWith("Insufficient points");
  });

  it("未登録アドレスの残高は0", async function () {
    const { points, user2 } = await deployFixture();

    // 存在しないキーのデフォルト値は0
    expect(await points.getBalance(user2.address)).to.equal(0);
  });
});

ネストされた mapping

mappingの値としてさらにmappingを使うことで、2次元以上のデータ構造を表現できます。

// contracts/AccessControl.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract AccessControl {
    // ロール名 => (アドレス => 権限の有無)
    mapping(string => mapping(address => bool)) private roles;

    // アドレス => (コントラクトアドレス => 承認額)
    mapping(address => mapping(address => uint256)) public allowances;

    event RoleGranted(string role, address indexed account);
    event RoleRevoked(string role, address indexed account);

    address public admin;

    constructor() {
        admin = msg.sender;
        roles["admin"][msg.sender] = true;
    }

    modifier onlyAdmin() {
        require(roles["admin"][msg.sender], "Not admin");
        _;
    }

    /// @notice ロールを付与する
    function grantRole(
        string memory role,
        address account
    ) public onlyAdmin {
        roles[role][account] = true;
        emit RoleGranted(role, account);
    }

    /// @notice ロールを取り消す
    function revokeRole(
        string memory role,
        address account
    ) public onlyAdmin {
        roles[role][account] = false;
        emit RoleRevoked(role, account);
    }

    /// @notice ロールを持っているか確認する
    function hasRole(
        string memory role,
        address account
    ) public view returns (bool) {
        return roles[role][account];
    }

    /// @notice 承認額を設定する
    function setAllowance(
        address spender,
        uint256 amount
    ) public {
        allowances[msg.sender][spender] = amount;
    }

    /// @notice 承認額を確認する
    function getAllowance(
        address owner,
        address spender
    ) public view returns (uint256) {
        return allowances[owner][spender];
    }
}

ネストされたmappingのテスト

describe("AccessControl", function () {
  it("ロールの付与と確認ができる", async function () {
    const [admin, curator, visitor] = await ethers.getSigners();
    const AC = await ethers.getContractFactory("AccessControl");
    const ac = await AC.deploy();

    // 学芸員ロールを付与
    await ac.grantRole("curator", curator.address);

    expect(await ac.hasRole("curator", curator.address)).to.be.true;
    expect(await ac.hasRole("curator", visitor.address)).to.be.false;
    expect(await ac.hasRole("admin", admin.address)).to.be.true;
  });

  it("承認額の設定と確認ができる", async function () {
    const [owner, spender] = await ethers.getSigners();
    const AC = await ethers.getContractFactory("AccessControl");
    const ac = await AC.deploy();

    await ac.setAllowance(spender.address, 1000);

    expect(
      await ac.getAllowance(owner.address, spender.address)
    ).to.equal(1000);

    // 別のアドレスの承認額は0
    expect(
      await ac.getAllowance(spender.address, owner.address)
    ).to.equal(0);
  });
});

構造体(struct)とmappingの組み合わせ

mappingの値として構造体を使うのは、非常に一般的なパターンです。

// contracts/CulturalItemRegistry.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract CulturalItemRegistry {
    struct CulturalItem {
        string name;
        string institution;
        string dateCreated;
        address registeredBy;
        uint256 registeredAt;
        bool exists;
    }

    // itemId => CulturalItem
    mapping(uint256 => CulturalItem) public items;

    // 所蔵機関 => アイテムIDの配列
    mapping(string => uint256[]) public itemsByInstitution;

    // 登録者 => アイテムIDの配列
    mapping(address => uint256[]) public itemsByRegistrar;

    uint256 public nextItemId;

    event ItemRegistered(uint256 indexed itemId, string name);

    function registerItem(
        string memory name,
        string memory institution,
        string memory dateCreated
    ) public returns (uint256) {
        uint256 itemId = nextItemId++;

        items[itemId] = CulturalItem({
            name: name,
            institution: institution,
            dateCreated: dateCreated,
            registeredBy: msg.sender,
            registeredAt: block.timestamp,
            exists: true
        });

        // 逆引き用のマッピングも更新
        itemsByInstitution[institution].push(itemId);
        itemsByRegistrar[msg.sender].push(itemId);

        emit ItemRegistered(itemId, name);
        return itemId;
    }

    /// @notice アイテムを取得する
    function getItem(uint256 itemId)
        public view returns (CulturalItem memory)
    {
        require(items[itemId].exists, "Item not found");
        return items[itemId];
    }

    /// @notice 所蔵機関ごとのアイテムIDリストを取得
    function getItemsByInstitution(string memory institution)
        public view returns (uint256[] memory)
    {
        return itemsByInstitution[institution];
    }

    /// @notice 登録者ごとのアイテムIDリストを取得
    function getItemsByRegistrar(address registrar)
        public view returns (uint256[] memory)
    {
        return itemsByRegistrar[registrar];
    }
}

mappingを使う際の注意点

1. 列挙(イテレーション)ができない

mappingのすべてのキーをループで回すことはできません。キーの一覧が必要な場合は、別途配列で管理します。

// 悪い例: mappingだけでは全ユーザーの一覧が取れない
mapping(address => uint256) public balances;

// 良い例: 配列でキー一覧を管理する
mapping(address => uint256) public balances;
address[] public users;

2. deleteの挙動

delete を使うとデフォルト値にリセットされますが、キー自体は消えません。

mapping(address => uint256) public data;

data[msg.sender] = 100;
delete data[msg.sender];
// data[msg.sender] は 0 に戻るが、キーが「削除」されたわけではない

3. ガスコストの考慮

ストレージへの書き込みはガスコストが高い操作です。mappingの値を頻繁に更新する場合はガスコストに注意が必要です。

まとめ

本章では、Solidityの mapping について学びました。基本的な使い方から、ネストされたmapping、構造体との組み合わせまでを実践しました。mappingはスマートコントラクト開発で最も頻繁に使うデータ構造であり、しっかりと理解しておくことが重要です。

次章では、modifier を使ったアクセス制御について学びます。

関連記事