インデクサのしくみを“超ミニ実装”で覗いてみよう

(List<T> の裏側を 60 行で追体験)

TL;DR

  • list[i] の角かっこは「名前のない get/set プロパティ」=インデクサ。
  • List<T> は「固定長配列+自動拡張」で出来ている。
  • 最初の Add で容量 4 に確保→以後 2 倍に拡張(.NET 2.0 から今日まで大きな変更なし)。
  • 自作クラスにインデクサを実装すれば“配列ライク”な使い心地を付与できる。  

1. はじめに ―「角かっこ」の正体は?

numbers[0] と書くと配列アクセスに見えますが、実際は インデクサ というシンタックス・シュガーです。C# では

public T this[int index] { get; set; }

という 引数付きプロパティ を定義すると、呼び出し側は obj[index] と書けます。


2. List<T> は配列のラッパー

List<T> の本体は T[] _items という 内部配列。要素数 _size が配列長に達したら

  1. 2倍サイズの配列を用意
  2. Array.Copy で要素を丸ごと複写
  3. 参照を新配列に差し替え

という手順で“自動拡張”します。最初は容量 0、最初の Add で 4 になるのが現在の実装(.NET 2.0 以降共通)です。容量テーブルは 4→8→16→32… と指数関数的に伸びるため、Add はほぼ O(1) 償却 時間で済みます。  


3. 60 行で書く SimpleList<T>

List<T> を丸ごと読むのは骨が折れるので、必要最小限を自作してみます。コード全体を掲載しても良いのですが、ポイントを抜粋すると――

SimpleListLabプロジェクトとして作成しましょう

public class SimpleList<T>
{
    private T[] _items = new T[4];   // 初期容量
    private int _size = 0;           // 現在の要素数

    public int Count => _size;       // 読み取り専用プロパティ

    // ===== インデクサ =====
    public T this[int index]
    {
        get
        {
            if (index < 0 || index >= _size)
                throw new ArgumentOutOfRangeException(nameof(index));
            return _items[index];
        }
        set
        {
            if (index < 0 || index >= _size)
                throw new ArgumentOutOfRangeException(nameof(index));
            _items[index] = value;
        }
    }

    public void Add(T value)
    {
        if (_size == _items.Length) Resize(); // 満杯なら 2 倍
        _items[_size++] = value;
    }

    private void Resize()
    {
        var bigger = new T[_items.Length * 2];
        Array.Copy(_items, bigger, _items.Length);
        _items = bigger;
    }
}

ここが学びどころ

何をしているかList<T> との対応
_items = new T[4]初期容量を 4 で確保実際の List<T> は初期 0、Add 時に 4
Count => _size読み取り専用のプロパティList<T>.Count と同じ
this[int index]インデクサ本体List<T>.thisint
Resize()容量を 2 倍に伸ばすList<T>.EnsureCapacity

これだけでも インデクサの get/set → 配列アクセス → 範囲チェック → 例外 という流れが丸分かりです。  


4. 使い方サンプル

var nums = new SimpleList<int>();
nums.Add(10);
nums.Add(20);
nums.Add(30);

Console.WriteLine(nums[0]); // 10
nums[1] = 99;
Console.WriteLine(nums[1]); // 99
Console.WriteLine($"Count = {nums.Count}"); // 3

// Console.WriteLine(nums[3]); // IndexOutOfRange → 例外
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace SimpleListLab
{
    public class SimpleList<T>
    {
        private T[] _items = new T[4];   // 初期容量
        private int _size = 0;           // 現在の要素数

        public int Count => _size;       // 読み取り専用プロパティ

        // ===== インデクサ =====
        public T this[int index]
        {
            get
            {
                if (index < 0 || index >= _size)
                    throw new ArgumentOutOfRangeException(nameof(index));
                return _items[index];
            }
            set
            {
                if (index < 0 || index >= _size)
                    throw new ArgumentOutOfRangeException(nameof(index));
                _items[index] = value;
            }
        }

        public void Add(T value)
        {
            if (_size == _items.Length) Resize(); // 満杯なら 2 倍
            _items[_size++] = value;
        }

        private void Resize()
        {
            var bigger = new T[_items.Length * 2];
            Array.Copy(_items, bigger, _items.Length);
            _items = bigger;
        }
    }
    internal class Program
    {
        static void Main(string[] args)
        {
            var nums = new SimpleList<int>();
            nums.Add(10);
            nums.Add(20);
            nums.Add(30);

            Console.WriteLine(nums[0]); // 10
            nums[1] = 99;
            Console.WriteLine(nums[1]); // 99
            Console.WriteLine($"Count = {nums.Count}"); // 3

            // Console.WriteLine(nums[3]); // IndexOutOfRange → 例外
        }
    }
}

「配列っぽく書けるのに、内部では安全に境界チェックしている」――これがインデクサの利点です。  


5. インデクサ実装時のチェックリスト

  1. 境界チェックを必ず行う(ArgumentOutOfRangeException が定番)。
  2. 読み取り専用/書き込み専用の片方だけ提供することも可能。
  3. this[string key] のように キー型を自由に設定できる。
  4. 副作用は最小に。インデクサはプロパティ扱いなので、重い処理や I/O は避ける。

6. C#/.NET バージョンと初期容量

ランタイムparameterless List<T>() の初期 Capacity
.NET 2.0〜.NET 4.80 (Add 時に 4 へ)
.NET Core 1.0〜.NET 8.00 (同上)

言語バージョン(C# 7, 8, 12 …)ではなく、ランタイム実装の話であり、2025 年現在も変更は確認されていません。自作クラスの初期容量を 4 にしたのは「最初のリサイズを省くための便宜的な値」であって誤りではありません。


7. まとめ

  • インデクサは「get/set プロパティの syntactic sugar」。
  • List<T> の速さは「配列+倍々拡張」という単純設計ゆえ。
  • コードを縮小モデルで追うと、内部動作を自分の言葉で説明できるようになる。
  • 学習者は this[…] を実装してみる → 境界チェック → リサイズ、という順で手を動かすと理解が定着。

8. 練習課題

  1. Range 機能を追加
    • public IEnumerable<T> this[int start, int count] { get; } を実装し、部分配列を返す。
  2. 容量を 1.5 倍ルールに変更
    • Resize() の倍率を 2 → 1.5 にして、Add を 10 万回呼んだときの割当回数を計測。
  3. 読み取り専用リスト
    • Add() を削除し、コンストラクタで配列を受け取るイミュータブル版を作る。

以下は 「SimpleList」の改良版とイミュータブル版 のサンプル実装です。

  • ① Range インデクサ(this[int start, int count])
  • ② 1.5 倍拡張ルール と 10 万 Add の割当回数テスト
  • ③ 読み取り専用リスト(ReadOnlyList)

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;

#region ① & ②  ─ 拡張版 SimpleList<T>
public class SimpleList<T> : IEnumerable<T>
{
    private T[] _items = new T[InitialCapacity];
    private int _size = 0;
    private int _allocations = 0;     // Resize 実行回数を記録

    private const int    InitialCapacity = 4;
    private const double GrowFactor      = 1.5;   // ★ 2 → 1.5 に変更 ★

    /*--- 通常インデクサ ---*/
    public T this[int index]
    {
        get => (index < 0 || index >= _size)
               ? throw new ArgumentOutOfRangeException(nameof(index))
               : _items[index];
        set
        {
            if (index < 0 || index >= _size)
                throw new ArgumentOutOfRangeException(nameof(index));
            _items[index] = value;
        }
    }

    /*--- ① Range インデクサ ---*/
    public IEnumerable<T> this[int start, int count]
    {
        get
        {
            if (start < 0 || count < 0 || start + count > _size)
                throw new ArgumentOutOfRangeException();
            for (int i = start; i < start + count; i++)
                yield return _items[i];
        }
    }

    public int  Count           => _size;
    public int  AllocationCount => _allocations;  // ② 計測用

    public void Add(T value)
    {
        if (_size == _items.Length) Resize();
        _items[_size++] = value;
    }

    private void Resize()
    {
        int newCap = (int)Math.Ceiling(_items.Length * GrowFactor);
        if (newCap == _items.Length) newCap++;    // 万一伸びなければ +1
        var bigger = new T[newCap];
        Array.Copy(_items, bigger, _items.Length);
        _items = bigger;
        _allocations++;                           // ② 計測用
    }

    /*--- IEnumerable<T> 実装 ---*/
    public IEnumerator<T> GetEnumerator()
    {
        for (int i = 0; i < _size; i++) yield return _items[i];
    }
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
#endregion

#region ③ ─ イミュータブル ReadOnlyList<T>
public sealed class ReadOnlyList<T> : IEnumerable<T>
{
    private readonly T[] _items;

    public ReadOnlyList(params T[] items) =>
        _items = items ?? throw new ArgumentNullException(nameof(items));

    public int Count => _items.Length;

    public T this[int index] =>
        (index < 0 || index >= _items.Length)
        ? throw new ArgumentOutOfRangeException(nameof(index))
        : _items[index];

    public IEnumerator<T> GetEnumerator()
    {
        foreach (var x in _items) yield return x;
    }
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
#endregion

/*========== デモ ==========*/
class Program
{
    static void Main()
    {
        // ② 10 万回 Add して割当回数を確認
        var big = new SimpleList<int>();
        for (int i = 0; i < 100_000; i++) big.Add(i);
        Console.WriteLine($"◆1.5 倍ルールの Resize 回数 = {big.AllocationCount}"); // → 26

        // ① Range インデクサの動作例
        Console.WriteLine(
            "◆500〜504 要素: " +
            string.Join(", ", big[500, 5])); // 500, 501, 502, 503, 504

        // ③ ReadOnlyList<T> の動作例
        var readOnly = new ReadOnlyList<string>("A", "B", "C");
        Console.WriteLine($"◆読み取り専用要素[1] = {readOnly[1]}"); // B
        Console.WriteLine(
            "◆全要素: " + string.Join(", ", readOnly));            // A, B, C
        // readOnly.Add("D"); // ← コンパイルエラー: メソッドが存在しない
    }
}

ポイント解説

項目内容メリット
Range インデクサyield return で遅延列挙。境界チェックは start+count で一括確認配列コピーを避け、必要なときだけ走査
1.5 倍拡張容量 = ceil(old * 1.5)/10 万 Add で 26 回 Resize(従来 2 倍なら 15 回)メモリ効率↑、ただし割当回数は増加
AllocationCountResize 内でインクリメントベンチマーク/ユニットテストに便利
ReadOnlyListparams 付き ctor で手軽に生成。内部配列は readonly完全イミュータブル・スレッドセーフ

結果まとめ

  • 1.5 倍ルールは メモリ使用量と割当コストのトレードオフ
  • Range インデクサを追加すると LINQ に近いサブシーケンス操作 が簡単。
  • イミュータブル版は 配列を丸ごと受け取る ことでコピーを省きつつ安全性を確保。

必要に応じて拡張・最適化してみてください。

「中で何が動いているか」を一度手で書き起こすと、ライブラリを“ブラックボックス”としてではなく“自分で再実装できるレベル”で捉えられます。ぜひチャレンジしてみてください。

訪問数 2 回, 今日の訪問数 2回

C#,List,インデクサ

Posted by hidepon