ufcpp-live / UfcppLiveAgenda

@ufcpp live streaming agenda
MIT License
24 stars 2 forks source link

【C# 9.0 個別機能話】initプロパティ #20

Closed ufcpp closed 3 years ago

ufcpp commented 3 years ago

https://youtu.be/Ls3I5xnEh6A

しばらく、C# 9.0 の機能を1個1個紹介していく配信をしようかということで。 最初は record がらみから。 record を話す上で説明順序的に init が先の方がよさそうなので今日はこの話。

さかのぼると、匿名型は以下のように nominal な(プロパティ名を指定した)初期化ができるけども、immutable。

var anonymous = new { X = 1 };

record を作るにあたって、record は(デフォルトでは) immutable であるべきという話がまずあって。 そこで導入されることになったのが init アクセサー。

record A
{
    public int X { get; init; }
}

record B : A
{
    public int Y { get; init; }
}

readonly struct S
{
    public readonly int _x;
    public int X { get => _x; init => _x = value; }
}
ufcpp-live commented 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# コンパイラーが特殊対応してる)。

ufcpp-live commented 3 years ago

mutable な参照型に「値による比較」を入れちゃいけないよという話。

https://gist.github.com/ufcpp/6336a4c1033e431c6732e82e03029bc7

ハッシュテーブルの類(HashSet, Dictionary)が特にまずい。

ufcpp-live commented 3 years ago

匿名型は 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 }));
ufcpp-live commented 3 years ago

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; }
}
ufcpp-live commented 3 years ago

なので 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...
}
ufcpp-live commented 3 years ago

特にコンストラクター(positional/位置指定の初期化)で困るのが、メンバーの追加

using System;

// コンストラクターはメンバー追加に対して弱い
Console.WriteLine(new A(1, 2));

record A(int X, int W = 0, int Y = 0); // 真ん中にメンバーを足すとずれる
ufcpp-live commented 3 years ago

派生もやっかい。 基底クラスのコンストラクター引数との重複がどうしても出てくる。

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);
ufcpp-live commented 3 years ago

ちょっと話はそれるものの、プライマリコンストラクターに対する 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();
    }
}
ufcpp-live commented 3 years ago

もう1段話がそれるけど、

X is < 0 and > 10 (and と or を間違えてて常に false になってる)がちゃんとコンパイルエラーになるの、C# 9.0 賢い!

ufcpp-live commented 3 years ago

本題に戻って、

なので、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; }
}
ufcpp-live commented 3 years ago

これも話がそれるというか、「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 全般回」になると思う。

ufcpp-live commented 3 years ago

(これも次回にやるべきネタ)

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;
}
ufcpp-live commented 3 years ago

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; }
}
ufcpp-live commented 3 years ago

そのために 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 な結果になった…
}
ufcpp-live commented 3 years ago

本題に戻って、

オブジェクト初期化子前提で 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; } // 後から追加
}
ufcpp-live commented 3 years ago

で、実態として、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; }
}
ufcpp-live commented 3 years ago

「コンパイラーが頑張る」という選択をしたものの、頑張るのを義務付けないとやばい機能になってる。

逆に言うと頑張れない言語にとっては…

例えば 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

正しく「未対応」なので、「書き換えられてまずい」みたいなことは起きないものの、エラーメッセージが不親切なことになってる。

ufcpp-live commented 3 years ago

逆に、結構厳しめのチェックをしているので、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; }
}
ufcpp-live commented 3 years ago

ここで再び値のバリデーションの話に。 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; }
}
ufcpp-live commented 3 years ago

なので、「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 なのか…

ufcpp-live commented 3 years ago

「コンストラクターか 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;
    }
}
ufcpp-live commented 3 years ago

配信で話してみた感想:

.NET を1から設計しなおすならもうちょっとマシな設計になったとは思うけど… 1から考えるにしても案外厄介かも。

ufcpp-live commented 3 years ago

ちなみに、「コンパイラーが頑張ることを義務付ける」に使われてる仕組みは modreq ってものなんだけど、これだけで1記事・1配信になっちゃう…

https://github.com/ufcpp/UfcppSample/issues/295 https://github.com/ufcpp-live/UfcppLiveAgenda/issues/4

ufcpp-live commented 3 years ago

余談:

++C++ がコンパイルエラーになるかどうかは、90年代後半~2000年代前半(うちのサイトができたころ)には「C++ の標準仕様に準拠してるかどうかベンチマーク」として有名だった。通称 UFO 演算子。

ufcpp-live commented 3 years ago

インクリメント演算子自体が「燃料」になる危ういやつ。 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
ufcpp-live commented 3 years ago

こないだ 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;
}
ufcpp-live commented 3 years ago

「1つもステートメントがないと top-level ステートメント扱いを受けなくて、『Mainがない』扱いを受けちゃうんで。」 「; 1個だけでも有効なステートメントなので、とりあえず空打ち。」

image

ufcpp-live commented 3 years ago

ちなみに、「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