値とストレージの区別

値と値を配置する場所(ストレージ)を区別することで有名な問題を簡単に説明できることを述べる。

用語: 値とストレージ

はじめに用語を決めておく。

数値や日付などの情報を総称して「値」と呼ぶ。 値の一部を書き換えることはできないとする。 言い換えると、イミュータブルなものだけを値と呼ぶことにする。

一方で、値を配置する場所を「ストレージ」と呼ぶ。 典型例は変数である。 変数を宣言しておくと、その変数が持つ値を配置する場所(ストレージ)が確保される。 変数を初期化したり代入したりすると、ストレージに配置された値を書き換えることができる。 同様にオブジェクトのフィールドもストレージとみなす。

(なおストレージという単語はC言語から借用した: Value categories - cppreference.com)

変数がストレージを表してたり値を表してたりするやつ

変数は値として振る舞う場面と、ストレージとして振る舞う場面がある。 この代入文では左辺の x は変数に割り当てられたストレージを表している (とみなせる)。 右辺の x はそこに配置された値を表している (とみなせる)。

//      v これは値
    x = x + 1;
//  ^ これはストレージ

値と「変更可能なストレージ」を区別してみる

値と「変更可能なストレージ」を区別することで以下の問題を説明できる。

「長方形クラスから継承して正方形クラスを作るとおかしくなる」問題

これはクラスの継承関係の設計に関連して知られている、有名な問題である。

はじめに長方形クラスを作る。長方形は幅と高さがある。 (長方形の幅と高さは変更可能にしておく。この問題が提案された当時、おそらく、イミュータブルなオブジェクトの価値は浸透していなかったと思う。)

/// 長方形
class Rectangle
{
    public int Width { get; set; }
    public virtual int Height { get; set; }
}

次に正方形クラスを作る。 ここで、数学的には「すべての正方形は長方形でもある」ことを思い出す。 そのため正方形クラスは、長方形クラスから継承するべき……だと思うかもしれない。

/// 正方形
class Square
    : Rectangle
{
    // 幅はそのまま受け継ぐ。
    // public int Width { get; set; }

    // 高さはオーバーライドして値が常に幅と等しくなるようにする。
    public override int Height
    {
        get => this.Width;
        set => this.Width = value;
    }
}

実際に正方形のオブジェクトを作って、長方形にアップキャストし、その幅を変更してみよう。

    // 5x5 の正方形を作って、長方形にアップキャストする。
    var rect = (Rectangle)(new Square() { Width = 5 });
    Console.Write("rect: {0}x{1}", rect.Width, rect.Height);
    //=> rect: 5x5

    // 幅を変えてみる。
    rect.Width = 6;
    Console.Write("rect: {0}x{1}", rect.Width, rect.Height);
    //=> rect: 6x6

「長方形の幅を変更したら、同時に高さも変わる」という怪現象が発生している。 この挙動はリスコフの置換原則に抵触している可能性が高い。


ここで長方形の幅と高さは変更可能なストレージになっている。 前述の長方形クラスは、長方形そのものというより、長方形を値に持つストレージである。

数学的には「正方形は長方形でもある」といえるが、「正方形を値に持つストレージは、長方形を値に持つストレージでもある」とはいえない。

    仮に、正方形を値に持つストレージは、すべて長方形を値に持つストレージでもあるとする。
    sqを正方形を値に持つストレージとする。
    sqをアップキャストして、長方形を値に持つストレージとみなす。それをrectと呼ぶ。
    rectに5x6の長方形を代入する。
    sqの値を取り出すと、5x6の長方形が出てくる。(矛盾)

そういうわけで上述の長方形と正方形の継承問題は、幅や高さを変更可能にしたことが問題だったといえる。 幅と高さをイミュータブルにすると解決する。

class Rectangle
{
    public Rectangle(int width, int height)
    {
        this.Width = width;
        this.Height = height;
    }

    public int Width { get; } // setterを削った。
    public int Height { get; }
}

class Square
    : Rectangle
{
    public Square(int size)
        : base(size, size)
    {
    }
}

なお実際には、「長方形は正方形でもある」ことを忘れて、この2つのクラスに継承関係を持たせないという選択肢もある。 例えば長方形と正方形が共通して持つ性質をインターフェイスとして定めておけば十分かもしれない。

/// 図形
interface IShape
{
    double Area();
}

class Rectangle
    : IShape // IShapeを実装する
{
    double Area() => (double)this.Width * this.Height;
    // ...
}

class Square
    : IShape // Rectangleは継承しない
{
    double Area() => (double)this.Width * this.Width;
    // ...
}

「購入記録に商品のIDを持たせるとおかしくなる」問題

この問題はデータベース(RDB)の設計に関連して知られている。

通販サイトのデータベースを考える。

  • 商品の名前と価格を持つための商品テーブルがある。
  • 誰が何を購入したかを記録するための購入テーブルがある。

購入テーブルの「何を購入したか」は、商品IDを持たせることで表す。(←これがダメ)

create table purchases(
    `購入ID` bigint primary key,
    `購入者ID` bigint not null,
    `商品ID` bigint not null
);

次のような流れで問題が生じる。

  • 100円の商品Xを登録した。
  • Aさんが商品Xを購入した。
  • 商品Xの価格を120円に変更した。

このときデータベースを見ると:

  • 商品Xの価格は120円である
  • Aさんが商品Xを購入した

ということだけ記録されている。

Aさんが商品Xを100円で購入したという記録が消失してしまった。


商品IDは商品テーブル内のレコードを指しているが、レコードは「変更可能なストレージ」である。 購入テーブルの「何を購入したか」にはストレージではなく値を持たせる必要があった。

解決策はいろいろ考えられるが、一例として購入時の価格を複製する方法がある。(直交性が下がる。)

create table purchases(
    `購入ID` bigint primary key,
    `購入者ID` bigint not null,
    `商品ID` bigint not null,

    -- 購入時の価格
    `価格` int not null
);

商品名は変更されないから複製しなくていいや、などの判断はありうる。 (商品がテーブルから削除された (deleteされた) ときどうするかという話になってくる。廃止された商品を削除するのではなく履歴で持てばいい。)

余談: 参照セル

F# には「参照セル」(ref)というオブジェクトがある。(なお F# で参照セルの使用は推奨されてない。)

参照セルは本稿の「変更可能なストレージ」そのものといえる。

    // ref関数で新しい参照セルを作る。
    // ↓はint型の値を持つ参照セルを作って、初期値0を入れている。
    let counter = ref 0

    // ! 演算子で現在の値を取り出す。
    let n = !counter

    assert (n = 0)

    // := 演算子で値を設定する。
    counter := 1

    assert (!counter = 1)

参照セルの型は 'T ref ('T は型パラメータ) で、値の型がintの場合は int ref である。 値であるintと、ストレージである int ref を型で区別しているといえる。

冒頭で変数が値だったりストレージだったりする話を書いたが、参照セルを導入すると「変数は常に値を表す」ような構文も実現できる。 例えばインクリメントする式はこう書くことになる:

    x := !x + 1

x = x + 1 は等式としておかしいといわれることもない。

また、参照セルの型 (型構築子 ref) は共変でないことが知られている。 型Aの値を型Bにアップキャストできるとき、型Aは型Bの部分型であるといって A <: B と表す。 A <: B のとき A ref <: B ref になるなら ref は共変であるといえる……が、実際そうはならない。

例えば 猫, 犬, 動物 という型があって、猫 <: 動物犬 <: 動物 という関係があるとする。 猫の参照セルを動物の参照セル型にアップキャストして、そこに犬を代入すると、猫の参照セルに犬が入ってしまう。 (これは前述の長方形・正方形の一般化である。)

関連記事