Closed ufcpp closed 3 years ago
配信中かき捨てコード
匿名型の展開結果 (Visual Studio のリファクタリングで「クラスに変換」がやってくれるのが大体同じ結果)
// C# 3.0
var x1 = new { X = 1, Y = 2 }; // 匿名型
var x2 = new NewClass(1, 2);
//var x2 = new B { X = 1, Y = 2 }; // オブジェクト初期化子
internal class NewClass
{
public int X { get; }
public int Y { get; }
public NewClass(int x, int y)
{
X = x;
Y = y;
}
public override bool Equals(object? obj)
{
return obj is NewClass other &&
X == other.X &&
Y == other.Y;
}
public override int GetHashCode()
{
return HashCode.Combine(X, Y);
}
}
オブジェクト初期化子だったものがコンストラクター呼び出しに置き換わる(C# コンパイラーが特殊対応してる)。
mutable な参照型に「値による比較」を入れちゃいけないよという話。
https://gist.github.com/ufcpp/6336a4c1033e431c6732e82e03029bc7
ハッシュテーブルの類(HashSet
, Dictionary
)が特にまずい。
匿名型は LINQ と同時期に入ったもので、特に O/R Mapper のキーとして使ったりしたので、immutable でないとまずかった。
using System.Linq;
var x1 = new { X = 1, Y = 2 }; // 匿名型
// 匿名型は値による比較を持ってる(Equals, GetHashCode 生成している)
// 匿名型は immutable でないとまずい
var set = Enumerable.Range(0, 10)
.Select(i => new { i })
.ToDictionary(x => x);
System.Console.WriteLine(set.ContainsKey(new { i = 1 }));
C# 9.0 の record の本質は「値による比較」(Equals, GetHashCode)のコンパイラー生成。
書こうと思えば mutable な record も書けて、この場合、もろに前述の「mutable な参照型の値比較はダメ」地雷を踏む。
using System;
using System.Collections.Generic;
var x1 = new A { X = 1, Y = 2 }; // 匿名型だったものを record に置き換え
var set = new HashSet<A>();
for (int i = 0; i < 10; i++)
{
var x = new A { X = i };
set.Add(x); // HashSet に登録した後で…
x.X = -1; // 値を書き換え
}
// 以下のコード、全部 false になる。
// 「自分自身の検索ができないオブジェクト」ができてる。
foreach (var x in set)
{
Console.WriteLine(set.Contains(x));
}
// 匿名型を匿名じゃなくしたようなもの
record A
{
public int X { get; set; } // record も mutable だとまずい
public int Y { get; set; }
}
なので record は(少なくともデフォルト/一番短い書き方では) immutable であるべき。
当初はプライマリコンストラクターだけが考えられてた。 (でも、コンストラクターが必須なのにはそれなりの面倒があって…)
using System;
Console.WriteLine(new A { }); // これができなくなる、コンストラクター必須
// record、プライマリコンストラクターとセットで設計されてた
record A(int X);
// ↓みたいなのを生成
class A
{
public int X { get; }
public A1(int X) => this.X = X;
// あと、Equals, GetHashCode, ToString...
}
特にコンストラクター(positional/位置指定の初期化)で困るのが、メンバーの追加
using System;
// コンストラクターはメンバー追加に対して弱い
Console.WriteLine(new A(1, 2));
record A(int X, int W = 0, int Y = 0); // 真ん中にメンバーを足すとずれる
派生もやっかい。 基底クラスのコンストラクター引数との重複がどうしても出てくる。
using System;
// コンストラクターはメンバー追加に対して弱い
Console.WriteLine(new B(1, 2));
record A(int X);
// B に、A からさらに Y を足したい
record B(
int X, // A の方の X と重複
int Y) : A(X);
ちょっと話はそれるものの、プライマリコンストラクターに対する body も書けないとまずいんだけど、C# 9.0 時点では実装されてない(10待ち)
// 現状
record A
{
public int X { get; }
public A(int X)
{
// プライマリコンストラクターでこの手の検証はどう入れる?
// C# 9.0 では無理
if (X is < 0 or > 10) throw new ArgumentOutOfRangeException();
this.X = X;
}
}
// 将来予定(10.0 マイルストーンのはず)
record A(int X)
{
// 現状の最有力案(未確定)
// () 抜きコンストラクターによってプライマリコンストラクターの body にする
A
{
if (X is < 0 or > 10) throw new ArgumentOutOfRangeException();
}
}
もう1段話がそれるけど、
X is < 0 and > 10
(and と or を間違えてて常に false になってる)がちゃんとコンパイルエラーになるの、C# 9.0 賢い!
本題に戻って、
なので、nominal (名前指定、オブジェクト初期化子前提)なメンバー定義に戻ってきた。
「オブジェクト初期化子までは書き換え可能で、その後書き換え不能」みたいな特殊対応をすることに。
それが init
。
using System;
var x = new A { X = 1 }; // ここまでは書き換え有効
var y = new B { X = 1, Y = 2 };
x.X = 2; // これを認めない。
y.Y = 3;
record A
{
public int X { get; init; }
}
record B : A
{
public int Y { get; init; }
}
これも話がそれるというか、「init だけで時間かかりそうだから今日は init に絞る」ってつもりだったのに record 一般の話になってしまったけど、record の派生は結構厄介。
// record の派生はめんどくさいです
// Java にも record が入ったけど、あっちは派生を認めなかった
record A(int X);
record B(int X, int Y ) : A(X); // A.X がすでにいるから、int X からのプロパティ生成が抑止される
// X は手書きのプロパティ X がいるのでそっち優先
// Y にはそれがないのでコンパイラー生成のプロパティY ができる
record C(int X, int Y)
{
// 手書きの X がいたら、プライマリコンストラクター引数の X よりも優先
public int X { get; init; } = X;
}
たぶん、次回のライブ配信はこの辺りを含む「record 全般回」になると思う。
(これも次回にやるべきネタ)
record の派生が厄介なのでいうと、Equals の対称性(交換法則)の話があったり。
// record (Equality) の派生は単純じゃないという話
// 手書きで下手なことをすると…
using System;
var a = new A { X = 1 };
var b = new B { X = 1, Y = 2 };
Console.WriteLine(a.Equals(b)); // True
Console.WriteLine(b.Equals(a)); // False
class A
{
public int X { get; init; }
public virtual bool Equals(A other) => X == other.X;
}
class B : A
{
public int Y { get; init; }
public override bool Equals(A other) => other is B b && Equals(b);
public virtual bool Equals(B other) => X == other.X && Y == other.Y;
}
record が標準でコンパイラー生成する Equals はちゃんと交換法則を満たす実装になってる。
// record の Equals はちゃんとしています
using System;
var a = new A { X = 1 };
var b = new B { X = 1, Y = 2 };
Console.WriteLine(a.Equals(b)); // False
Console.WriteLine(b.Equals(a)); // False
record A
{
public int X { get; init; }
}
record B : A
{
public int Y { get; init; }
}
そのために EqualityContract
っていう特殊なプロパティが生えてる。
だからと言って、正しく自分で交換法則を満たす Equals を自前実装できるかというと微妙…
using System;
var a = new A { X = 1 };
var b = new B { X = 1, Y = 2 };
Console.WriteLine(a.Equals(b)); // True
Console.WriteLine(b.Equals(a)); // False
record A
{
public int X { get; init; }
}
record B : A
{
// デフォルトでは GetType と大差ない
// カスタマイズしたいときに override
protected override Type EqualityContract => typeof(A);
// Y は equality に参加させない!
// A と同じ equality セマンティクスにしたい!
public int Y { get; init; }
// 配信中試みたけど True, False な結果になった…
}
本題に戻って、
オブジェクト初期化子前提で immutable を実現するために init を導入。 これなら、「後からの追加」に強い。
// nominal (名前指定の)初期化がしたい!
// オブジェクト初期化子で使える immutability が必要
var a = new A { X = 1 };
var b = new B { X = 1, Y = 2 };
record A
{
public int W { get; init; } // 後から追加
public int X { get; init; }
}
record B : A
{
public int Y { get; init; }
public int Z { get; init; } // 後から追加
}
で、実態として、init は C# コンパイラーのレイヤーで頑張ってる。
using System;
var a = new A { X = 1 };
a.X = 2; // これはダメ!とはいえ、そのチェックは C# コンパイラーがやってる
// init はコンパイラー上のトリックで何とかしてる
// 実は単なる set。
// CanWrite は true になっちゃう。
Console.WriteLine(p.CanWrite);
// dynamic は内部的にちゃんと init かどうかを見てそう。実行時エラー
((dynamic)a).X = 2; // これはコンパイルできるけど…
Console.WriteLine(a); // 実行時例外
// でも、リフレクションだとやりたい放題…
// これを禁止するとシリアライザー、デシリアライザーが死ぬ
typeof(A).GetProperty("X").SetValue(a, 3);
Console.WriteLine(a); // X = 3
record A
{
// init は set + modreq(属性的な何か) になる
public int X { get; init; }
}
「コンパイラーが頑張る」という選択をしたものの、頑張るのを義務付けないとやばい機能になってる。
逆に言うと頑張れない言語にとっては…
例えば C# で以下のようなクラスを用意したとして
public class Class1
{
public int X { get; set; }
public int Y { get; init; }
}
別言語で参照:
例1: VB (戻り値の型をサポートしていません)
Dim x = New ClassLibrary1.Class1 With {.X = 1, .Y = 2} ' .Y だけダメ
' C# だと「Y は init だからだめだよ」
' VB だと「戻り値の型をサポートしていません」(modreq のせい)
x.Y = 1
Console.WriteLine(x.X)
例2: F# (InvalidProgram でクラッシュ。どこの行が問題かすら出ない)
let y = ClassLibrary1.Class1(X = 1, Y = 2); // かけるけど、InvalidProgram
正しく「未対応」なので、「書き換えられてまずい」みたいなことは起きないものの、エラーメッセージが不親切なことになってる。
逆に、結構厳しめのチェックをしているので、C# としては結構大胆なことができる。
init
内では readonly
フィールドを書き換え可能。
using System;
var a = new S { X = -1 };
Console.WriteLine(a.X);
readonly struct S
{
// init アクセサー内では readonly の書き換え可能
private readonly int _x;
public int X { get => _x; init => _x = value; }
}
ここで再び値のバリデーションの話に。 init のせいでコンストラクターでのバリデーションが効かない。
using System;
var a = new S(1) { X = -1 };
// ↑ 呼ばれる順序は S のコンストラクター → X の init アクセサー
// x < 0 の検証をすり抜けちゃう。
Console.WriteLine(a.X);
readonly struct S
{
private readonly int _x;
public S(int x)
{
if (x < 0) throw new ArgumentOutOfRangeException();
_x = x;
}
public int X { get => _x; init => _x = value; }
}
なので、「init 後の検証用のコードを書く場所」みたいなのが本当は必要。 C# 9.0 時点では不可能。 C# 10.0 向けに提案あり。
using System;
var a = new S(1) { X = -1 };
Console.WriteLine(a.X);
readonly struct S
{
private readonly int _x;
public S(int x)
{
// わざわざコンストラクターで検証しているのに…
if (x < 0) throw new ArgumentOutOfRangeException();
_x = x;
}
// init の方がコンストラクターよりも後なので…
//public int X { get => _x; init => _x = value; } // -1 が OK になっちゃう。
// こう書かなきゃいけないけども2度手間&長い
public int X
{
get => _x;
init => _x = value < 0 ? throw new ArgumentOutOfRangeException() : value;
}
}
// 提案(10 マイルストーン)
record R(int X)
{
// ここにステートメントを書けるようにする?(6.0 時点没案)
// プライマリコンストラクターの body
R
{
// これは普通に new R(..) の時点で呼ばれる
}
// オブジェクト初期化子の直後に呼ばれる想定の何か
// "final initializer" とか呼ばれてる
init
{
if (X < 0) throw new ArgumentOutOfRangeException();
}
}
// () なしコンストラクター (プライマリコンストラクターの body)と↑は別物
//record R(int X)
//{
// R() /* : this(1) これが必須 */ { }
//}
final initialize って… final なのか initial なのか…
「コンストラクターか init
アクセサー内からだけ呼べる init
メソッド」みたいな提案もあり (これも C# 10.0 予定)
ちなみに、単に「コンストラクター内のローカル関数なら OK」ってやるのも多分大変。init
修飾子みたいなの必須になると思う。
class C
{
public int X { get; init; }
public Action Action { get; }
public C()
{
Initialize();
Action = () => { X = 1; }; // こういうのがあるからローカル関数でも init に触っちゃダメ
}
// 提案(10.0)されているのは init メソッド。
// プロパティ setter 以外にも init 修飾子。
private init void Initialize()
{
// これ、現状、無理。
X = 0;
}
}
配信で話してみた感想:
.NET を1から設計しなおすならもうちょっとマシな設計になったとは思うけど… 1から考えるにしても案外厄介かも。
ちなみに、「コンパイラーが頑張ることを義務付ける」に使われてる仕組みは modreq ってものなんだけど、これだけで1記事・1配信になっちゃう…
https://github.com/ufcpp/UfcppSample/issues/295 https://github.com/ufcpp-live/UfcppLiveAgenda/issues/4
余談:
++C++
がコンパイルエラーになるかどうかは、90年代後半~2000年代前半(うちのサイトができたころ)には「C++ の標準仕様に準拠してるかどうかベンチマーク」として有名だった。通称 UFO 演算子。
インクリメント演算子自体が「燃料」になる危ういやつ。
C-style for
とかいうもっと炎上させやすい燃料とも絡むので、意図的に燃やしたいときにはいい材料。
using System;
unsafe
{
int[] array = { 1, 2, 3, 4, -1 };
fixed (int* p = array)
{
int* C = p;
int i;
while (0 != (i = ++*C++))
{
Console.WriteLine(i);
}
}
}
//// for(;;)
//for (int i = 0; i < length; i++)
//{
//}
//// C# だと foreach なもの、Java, C++ は for in に
//for (int i in list) { }
//// JS
//for (let i of list) { }
//// golang は ++ はステートメント
//++i;
//// Swift は途中で ++i を禁止した
//// 同時に C-style for も禁止に
//++o++; // UFO
こないだ twitter でつぶやいた↓の書き方は意図的にきもいコードにしてある(while
の中で変数宣言 & int→bool 暗黙変換)。
#include <stdio.h>
int main()
{
int a[] { 1, 2, 3, 4, -1};
int* C = a;
while (int i = ++*C++)
{
printf("%d\n", i);
}
return 0;
}
「1つもステートメントがないと top-level ステートメント扱いを受けなくて、『Mainがない』扱いを受けちゃうんで。」
「;
1個だけでも有効なステートメントなので、とりあえず空打ち。」
ちなみに、「VB は対応していない」は今更。 C# 7.2, 7.3 のときの機能にも対応していない。
namespace ClassLibrary1
{
public ref struct RefStr
{
public Span<int> S;
}
public class Generic
{
public static T M<T>() where T : unmanaged => default;
}
public class Class1
{
public int X { get; set; }
public int Y { get; init; }
}
public class NomalClass
{
public int X { get; set; }
}
public record PrimaryCtor(int X, int Y);
}
' VB は init に未対応
Imports System
Module Program
Sub Main(args As String())
Dim x = New ClassLibrary1.Class1 With {.X = 1, .Y = 2} ' .Y だけダメ
Dim y = New ClassLibrary1.PrimaryCtor(1, 2) ' これは OK
' unmanaged 制約(C# 7.3)
Dim p = ClassLibrary1.Generic.M(Of Integer)();
' ref struct (C# 7.2)
Dim s = New ClassLibrary1.RefStr()
' C# だと「Y は init だからだめだよ」
' VB だと「戻り値の型をサポートしていません」(modreq のせい)
x.Y = 1
Console.WriteLine(x.X)
End Sub
End Module
https://youtu.be/Ls3I5xnEh6A
しばらく、C# 9.0 の機能を1個1個紹介していく配信をしようかということで。 最初は record がらみから。 record を話す上で説明順序的に init が先の方がよさそうなので今日はこの話。
さかのぼると、匿名型は以下のように nominal な(プロパティ名を指定した)初期化ができるけども、immutable。
record を作るにあたって、record は(デフォルトでは) immutable であるべきという話がまずあって。 そこで導入されることになったのが init アクセサー。