この記事は古いです。
C# 9.0でHaskellの代数的データ型(ADT)的な書き方をする で、最新の状況について書いています。C# 9.0ではかなり簡潔に書けるようになっています。
TL;DR
- C#7, C#8 に入った機能追加によってC#でもADTっぽい値が簡潔に使えるようになった
- しかしADTっぽい型を定義するところが記述量が多くて大変
- ADTっぽい型を定義するのも楽になる機能追加が入ると嬉しいなあ
まえがき
C#7, C#8 に入った機能追加1によって、 C#でもADTっぽい値の処理が簡潔に書けるようになりました。
しかし、他の言語のADT定義構文と比較すると、C#ではADTっぽい型を定義するために必要な記述量が大変多いです。
この記事では、ADTっぽい機能とは何か、どう役に立つかを「事前知識:代数的データ型(ADT)」セクションで紹介した後、「記述量の比較」セクションで実際に他言語の同様の機能との記述量を比較していきます。
事前知識:代数的データ型(ADT)
C#的には「ADTは値を持てるenumのような構造だよ」と説明するとわかりやすいかもしれません。
Wikipediaによる説明を参考にしても良さそうです。以下の「実例」のセクションを読んでも直感が得られるように書いてあります。
実例
以下の関数 StrictSqrtを例にとって説明します。
関数 StrictSqrtはint型の値を受け取って、その値の平方根のint型の値を返す関数です。ただし、通常のSqrtとは違い、負の値が渡された場合は引数が不正である旨を、負以外の引数が渡されて、整数の平方根が存在しないときはその旨を返すこととします。
このようなとき、どう書けばいいでしょうか?
値に着目してint型を返す関数として書き始めると、以下のように異なる種類を表す方法がうまく書けません。
int StrictSqrt(int n) {
if (n < 0) return //引数が不正だと言う情報が返せない
for(int i = 0; i <= n; i++) {
if (i * i == n) {
return i;
}
}
return //答えが存在しないという情報が返せない
}
種類に着目してenumで種類を返す関数として書き始めると、以下のように値がうまく書けません。
enum EnumResult { InvalidArgument, Fail, Success }
EnumResult StrictSqrt(int n) {
if (n < 0) return EnumResult.InvalidArgument;
for(int i = 0; i <= n; i++) {
if (i * i == n) return EnumResult.Success; // 結果の整数が返せない
}
return EnumResult.Fail;
}
上の2通りの方法を良いとこ取りして、以下のように値を持ったenumのように書けたら便利そうです。
Result StrictSqrt(int n) {
if (n < 0) return new InvalidArgument();
for(int i = 0; i <= n; i++) {
if (i * i == n) return new Success(i);
}
return new Fail();
}
StrictSqrt関数が返すResult型の値は、こちらもやはり値を持ったenumのように以下のように使えたら便利そうです。
for (int i = -5; i < 10; i++) {
switch (StrictSqrt(i)) {
case InvalidArgument():
Console.WriteLine($"{i}は不正な引数です。(負の数)");
break;
case Fail():
Console.WriteLine($"{i}の整数根は存在しません。");
break;
case Success(var n):
Console.WriteLine($"{i}のルートは{n}です。");
break;
}
}
実際、うまく継承を使ってResultとInvalidAtgument, Success, Failを定義すると、上のコードは有効なC#8.0のコードになります。
実行結果は以下のようになります。
// 結果
-5は不正な引数です。(負の数)
-4は不正な引数です。(負の数)
-3は不正な引数です。(負の数)
-2は不正な引数です。(負の数)
-1は不正な引数です。(負の数)
0のルートは0です。
1のルートは1です。
2の整数根は存在しません。
3の整数根は存在しません。
4のルートは2です。
5の整数根は存在しません。
6の整数根は存在しません。
7の整数根は存在しません。
8の整数根は存在しません。
9のルートは3です。
C#で使える値を持ったenumっぽいもの、すなわちADTっぽいものの雰囲気と、その使いみちがわかっていただけたでしょうか?
記述量の比較
C#ではADTっぽい値を使う側は簡潔に使えるが、型の定義が記述量が多いと先程述べました。
それを確認するために、数式を表すADTとそれを計算する関数calcをHaskell, OCaml, Scala, C#で作り、記述量を比較します。
本来は型の定義、値の作成、値の使用の順番で紹介するのが自然な流れかもしれません。しかしこの記事の目的は、これら3つのうち型の定義が他の2つと比べて記述量が多いことを実感してもらうことにあります。そのため、値の作成、値の使用の記述を先に比較して、最後に型の定義の記述に触れようと思います。
例の説明
今回ADTで表す数式は以下のBNFで表されるものです。
e ::= n | e + e | e - e | e * e | e / e
このBNFは、今回扱う数式が
- 整数
- 数式 + 数式
- 数式 - 数式
- 数式 * 数式
- 数式 / 数式
のいずれかの形をしていることを意味しています。
以下では、この数式を表すADTの型をExpr型(あるいはexpr型)としています。
また、今回の例中に直接は出てきませんが、main関数は数式myExprと、それを計算した値の両方を表示する操作を行うとします。2
値の作成
2*3+20/5
という数式を表す myExprを定義することとします。(計算すると10になります)
Haskell, OCaml, Scalaと比べてそれほど変わらない記述量(&それほど変わらない記法)で、C#でも記述できます。
Haskellでは以下のように書けます。
myExpr :: Expr
myExpr =
Add
(Mul
(CInt 2)
(CInt 3))
(Div
(CInt 20)
(CInt 5))
CIntは Const Int (定数整数)の意味です。
OCamlでは以下のように書けます。
let myExpr =
Add (
Mul (
(CInt 2),
(CInt 3)),
Div (
(CInt 20),
(CInt 5)))
Scalaでは以下のように書けます。
def myExpr: Expr =
Add(
Mul(
CInt(3),
CInt(2)),
Div(
CInt(20),
CInt(5)))
C#では以下のように書けます。
Expr myExpr =
new Add(
new Mul(
new CInt(3),
new CInt(2)),
new Div(
new CInt(20),
new CInt(5)));
確かにそれほど変わらない記述量ですね。
値の使用
数式の計算を行うcalc関数を比較します。
こちらもHaskell, OCaml, Scalaとそれほど変わらない記述量(&記法)でC#でも値が使用できます。
Haskellでは以下のように書けます。
calc :: Expr -> Int
calc (CInt i) = i
calc (Add l r) = calc l + calc r
calc (Sub l r) = calc l - calc r
calc (Mul l r) = calc l * calc r
calc (Div l r) = calc l `div` calc r
OCamlでは以下のように書けます。
let rec calc = function
| CInt i -> i
| Add (l, r) -> calc l + calc r
| Sub (l, r) -> calc l - calc r
| Mul (l, r) -> calc l * calc r
| Div (l, r) -> calc l / calc r
Scalaでは以下のように書けます。
def calc(expr: Expr): Int = expr match {
case CInt(i) => i
case Add(l, r) => calc(l) + calc(r)
case Sub(l, r) => calc(l) - calc(r)
case Mul(l, r) => calc(l) * calc(r)
case Div(l, r) => calc(l) / calc(r)
}
C#では以下のように書けます。
int Calc(Expr expr) => expr switch {
CInt(var i) => i,
Add(var l, var r) => Calc(l) + Calc(r),
Sub(var l, var r) => Calc(l) - Calc(r),
Mul(var l, var r) => Calc(l) * Calc(r),
Div(var l, var r) => Calc(l) / Calc(r)
};
こちらも確かにそれほど変わらない記述量で書けています。
型の定義
今まで見てきた部分では他の言語とそれほど変わらない記述量で書けていたのですが、型の定義の部分はOCaml, Haskell, Scalaと比べるとC#では必要な記述量がかなり多くなってしまいます。
Haskellでは以下のように書けます。
data Expr =
CInt Int
| Add Expr Expr
| Sub Expr Expr
| Mul Expr Expr
| Div Expr Expr
deriving Show
OCamlでは以下のように書けます。3
type expr =
| CInt of int
| Add of expr * expr
| Sub of expr * expr
| Mul of expr * expr
| Div of expr * expr
[@@deriving show]
Scalaでは以下のように書けます。
sealed abstract class Expr
case class CInt(value: Int) extends Expr
case class Add(left: Expr, right: Expr) extends Expr
case class Sub(left: Expr, right: Expr) extends Expr
case class Mul(left: Expr, right: Expr) extends Expr
case class Div(left: Expr, right: Expr) extends Expr
C#では以下のようにかなり記述量が多くなってしまいます。
class Expr {}
class CInt : Expr {
public int Value { get; }
public CInt(int value) => Value = value;
public void Deconstruct(out int value) => value = Value;
public override string ToString() => $"CInt({Value})";
}
class Add : Expr {
public Expr Left { get; }
public Expr Right{ get; }
public Add(Expr l, Expr r) => (Left,Right) = (l, r);
public void Deconstruct(out Expr l, out Expr r) => (l, r) = (Left, Right);
public override string ToString() => $"Add({Left}, {Right})";
}
class Sub : Expr {
public Expr Left { get; }
public Expr Right{ get; }
public Sub(Expr l, Expr r) => (Left,Right) = (l, r);
public void Deconstruct(out Expr l, out Expr r) => (l, r) = (Left, Right);
public override string ToString() => $"Sub({Left}, {Right})";
}
class Mul : Expr {
public Expr Left { get; }
public Expr Right{ get; }
public Mul(Expr l, Expr r) => (Left,Right) = (l, r);
public void Deconstruct(out Expr l, out Expr r) => (l, r) = (Left, Right);
public override string ToString() => $"Mul({Left}, {Right})";
}
class Div : Expr {
public Expr Left { get; }
public Expr Right{ get; }
public Div(Expr l, Expr r) => (Left,Right) = (l, r);
public void Deconstruct(out Expr l, out Expr r) => (l, r) = (Left, Right);
public override string ToString() => $"Div({Left}, {Right})";
}
つ、つらい、、、
まとめ
C#はADTっぽい値を気軽に使えるように進化してきました。それでもまだ、ADTっぽい型を定義するのはかなり苦労します。
型を定義する側も少ない記述量でできるようになるといいなあ。
追記
こちらで紹介されている recordsという機能のpropsalはだいぶ近い話かもしれません?
-
++C++というサイトにこのあたりの話が非常にわかりやすくまとまっています。 C#7の新機能#データ中心の設計や、is, switchの拡張(型スイッチ)などのページをおすすめします。 ↩
-
(deriving Show周りの条件を揃えたかったためです。Scalaのcase classには、deriving Showに相当するtoStringが実装されています。よく使う機能なので、本質ではないにせよ、この機能を持つ側で揃えて比較しようと思いました。) ↩
-
ppxを使っているのは若干unfairかもしれません? ↩