インデクサのしくみを“超ミニ実装”で覗いてみよう
(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 が配列長に達したら
- 2倍サイズの配列を用意
- Array.Copy で要素を丸ごと複写
- 参照を新配列に差し替え
という手順で“自動拡張”します。最初は容量 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. インデクサ実装時のチェックリスト
- 境界チェックを必ず行う(ArgumentOutOfRangeException が定番)。
- 読み取り専用/書き込み専用の片方だけ提供することも可能。
- this[string key] のように キー型を自由に設定できる。
- 副作用は最小に。インデクサはプロパティ扱いなので、重い処理や I/O は避ける。
6. C#/.NET バージョンと初期容量
ランタイム | parameterless List<T>() の初期 Capacity |
---|---|
.NET 2.0〜.NET 4.8 | 0 (Add 時に 4 へ) |
.NET Core 1.0〜.NET 8.0 | 0 (同上) |
言語バージョン(C# 7, 8, 12 …)ではなく、ランタイム実装の話であり、2025 年現在も変更は確認されていません。自作クラスの初期容量を 4 にしたのは「最初のリサイズを省くための便宜的な値」であって誤りではありません。
7. まとめ
- インデクサは「get/set プロパティの syntactic sugar」。
- List<T> の速さは「配列+倍々拡張」という単純設計ゆえ。
- コードを縮小モデルで追うと、内部動作を自分の言葉で説明できるようになる。
- 学習者は this[…] を実装してみる → 境界チェック → リサイズ、という順で手を動かすと理解が定着。
8. 練習課題
- Range 機能を追加
- public IEnumerable<T> this[int start, int count] { get; } を実装し、部分配列を返す。
- 容量を 1.5 倍ルールに変更
- Resize() の倍率を 2 → 1.5 にして、Add を 10 万回呼んだときの割当回数を計測。
- 読み取り専用リスト
- 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 回) | メモリ効率↑、ただし割当回数は増加 |
AllocationCount | Resize 内でインクリメント | ベンチマーク/ユニットテストに便利 |
ReadOnlyList | params 付き ctor で手軽に生成。内部配列は readonly | 完全イミュータブル・スレッドセーフ |
結果まとめ
- 1.5 倍ルールは メモリ使用量と割当コストのトレードオフ。
- Range インデクサを追加すると LINQ に近いサブシーケンス操作 が簡単。
- イミュータブル版は 配列を丸ごと受け取る ことでコピーを省きつつ安全性を確保。
必要に応じて拡張・最適化してみてください。
「中で何が動いているか」を一度手で書き起こすと、ライブラリを“ブラックボックス”としてではなく“自分で再実装できるレベル”で捉えられます。ぜひチャレンジしてみてください。
ディスカッション
コメント一覧
まだ、コメントがありません