値と値を配置する場所(ストレージ)を区別することで有名な問題を簡単に説明できることを述べる。
用語: 値とストレージ
はじめに用語を決めておく。
数値や日付などの情報を総称して「値」と呼ぶ。 値の一部を書き換えることはできないとする。 言い換えると、イミュータブルなものだけを値と呼ぶことにする。
一方で、値を配置する場所を「ストレージ」と呼ぶ。 典型例は変数である。 変数を宣言しておくと、その変数が持つ値を配置する場所(ストレージ)が確保される。 変数を初期化したり代入したりすると、ストレージに配置された値を書き換えることができる。 同様にオブジェクトのフィールドもストレージとみなす。
(なおストレージという単語は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
は共変であるといえる……が、実際そうはならない。
例えば 猫, 犬, 動物 という型があって、猫 <: 動物
と 犬 <: 動物
という関係があるとする。
猫の参照セルを動物の参照セル型にアップキャストして、そこに犬を代入すると、猫の参照セルに犬が入ってしまう。
(これは前述の長方形・正方形の一般化である。)