(C#) ジェネリック型制約違いのオーバーロード
小ネタです。
C# でジェネリックメソッドには型制約をつけることができます。 この時、C# ではシグネチャ (メソッド名、引数、戻り値など) が同じでジェネリック型制約のみが異なるメソッドはオーバーロードとして書くことができません。
public static void Foo<T>(this T value) where T : class { } // コンパイルエラー!! public static void Foo<T>(this T value) where T : struct, Enum { }
C# の文法的にどうしようもないので、以下のコードで妥協しました。
public static void Foo<T>(this T value, ClassMarker _ = default) where T : class { } public static void Foo<T>(this T value, EnumMarker _ = default) where T : struct, Enum { } public struct ClassMarker { } public struct EnumMarker { }
マーカー用の型を作って、メソッドの二番目の引数に追加してデフォルト引数を追加しておきます。
こうするとシグネチャが異なるのでオーバーロードでき、なおかつメソッド使用者側は二番目の引数を省略できるので2つとも同じ書き方ができます。
ライブラリとして public に露出するメソッドとしてはよくないかもしれませんが、使いどころによっては便利に書けるかもしれないです。
public enum Animal { Dog, Cat, } // クラス引数ののオーバーロード "hello".Foo(); // enum 引数のオーバーロード Animal.Dog.Foo();
(C#) ValueTuple のサイズとレイアウト
突然ですが、C# で sizeof((byte, int))
の値はいくつでしょう?(64bit 環境)
byte
が1バイト、int
が4バイト、つまり (byte, int)
は5バイト……ではないです。
答えは8バイト。アライメント上に乗るようにパディングがあるからですね。
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|
byte | - | - | - | int (0) | int (1) | int (2) | int (3) |
では、sizeof((byte, int, short))
の値はいくつでしょう?(64bit 環境)
byte
, int
, short
がそれぞれ1, 4, 2 バイトで、アライメント上に乗せると4, 4, 4 で12バイト?
いいえ、答えは8バイトです。レイアウトは以下のようになります。
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|
byte | - | short (0) | short(1) | int (0) | int (1) | int (2) | int (3) |
はい。お察しの通り、ValueTuple はフィールドレイアウトが順番通りではないです。
(byte, int, short)
の場合、byte
, int
, short
の順番にアライメントに乗せるよりも、順番を入れ替えて byte
, short
, int
の順にそろえたほうが構造体のサイズを小さくできるためこのようになります。
なぜ順番が入れ替わるのでしょう?
たとえば (byte, int)
型の場合、(byte, int)
は糖衣構文で実際の型は ValueTuple<byte, int>
という型になります。
これは以下のように定義されています。
[StructLayout(LayoutKind.Auto)] public struct ValueTuple<T1, T2> { public T1 Item1; public T2 Item2; }
StructLayout(LayoutKind.Auto)
が指定されているので、コンパイラが都合よく自由にメモリレイアウトを決めるということです。
これによって効率よく配置されてメモリサイズを削減するようになる利点があります。
しかし、レイアウトはコンパイラが決めるためネイティブとの相互運用には使えなくなってしまうのです。 実際にはアライメントを考慮すればコンパイラが決めるレイアウトはわかるのですが、仕様上これは保証されません。
ネイティブライブラリとの相互運用で、構造体の型定義をサボって楽するための匿名型として ValueTuple は使えないんですね。
おつらい。
WGSL 仕様メモ
WGSL
WGSL (WebGPU Shader Language) の文法や仕様の日本語解説が少ないので自分用メモ。仕様を網羅しているわけではなく、自分が書く上で必要そうな部分を適当に抜粋してまとめた。
Tour of WGSL と WGSL の仕様 から日本語に訳して簡単にまとめた。 仕様は長すぎて辛いけど困ったら見るべき。全部書いてある。
定数 (const)
const
の型は abstract-int と abstract-float として扱われ、精度は実際の変数に代入する場所で決まる。
変数
let
はイミュータブルな変数の宣言。ミュータブル変数は var
で宣言する。
var
は var<AS>
で宣言する。AS
はアドレス空間 (address spaces)
var<function>
関数内のローカル変数のデフォルトは var<function>
であり、普通ローカル変数では単に var
で宣言する。
初期値を省略した場合、ゼロ初期化される。
var<private>
シェーダーモジュール内でのグローバル変数。同一モジュール内の全ての関数からアクセス可能。 宣言時の初期値は const-expression でなければならない。省略した場合はゼロ初期化される。 モジュールグローバルでのみ宣言可能。
var<workgroup>
workgroup 内で共有されるミュータブル変数。各呼び出し (invocation) の間では atomic
や workgroupBarrier
で同期する必要がある。
モジュールグローバルでのみ宣言可能。
初期化式は必要なく、各ワーキンググループの実行時に自動でゼロ初期化される。
var<uniform>
ユニフォーム変数
var<storage>
(自分が使ってないので調べてない)
var<handle>
(自分が使ってないので調べてない)
let
イミュータブルな変数宣言。初期化子が必須。関数内ローカルでのみ宣言可能。 ポインタ型にすることが可能。
属性
@
から始まるのが属性。
@vertex fn vs_main() -> @builtin(position) vec4<f32> { ... }
行列
行列の引数省略時はゼロ初期化。引数は列優先。メモリ上の表現も列優先。
const zero_init = mat3x4<f32>(); /* | 0 0 0 | | 0 0 0 | | 0 0 0 | | 0 0 0 | */ const column_wise = mat3x2<f32>(vec2<f32>(1, 2), vec2<f32>(4, 5), vec2<f32>(6, 7)); /* | 1 4 6 | | 2 5 7 | */ const scalar_wise = mat2x3<f32>(1, 2, 3, 4, 5, 6); /* | 1 4 | | 2 5 | | 3 6 | */
配列
- Fixed-Sized Array
array<T, N>
- Runtime-Sized Array
array<T>
Fixed-Sized Array の配列長 N
は定数式ならリテラルでなくともよい。
const numbers = array<i32, 7>(1, 4, 2, 3); const three = 3; const foo: array<i32, three * 2 + 1> = numbers;
Runtime-Sized Array は Storage Buffer のリソースとしてのみ使える。 バッファ全体を表すか、バッファ全体を表す構造体の最後のメンバーとして使う。
要素数は実行時に決定され、変数に関連する BufferBinding のサイズに収まる範囲で可能な限り大きくなる。
実行時の配列長は arrayLength
で取得できる。
インデックスによって値を取得できるが、配列自体を変数に代入することはできない。
@group(0) @binding(0) var<storage> weights: array<f32>; struct PointLight { position: vec3f, color: vec3f, } struct LightData { meanIntensity: f32, point: array<PointLight>, } @group(0) @binding(1) var<storage> lights: LightData; fn number_of_lights() -> u32 { return arrayLength(&lights.point); } fn get_point_light(i: i32) -> PointLight { return lights.point[i]; } fn cannot_copy_whole_array() { // 通常の変数に代入はできない // var all_point_lights = lights.point; // Error }
構造体 (struct)
struct Data { a: f32, b: f32, }
constructible な構造体の場合、()
で初期化できる。
constructible とは
- スカラー型 (
u32
,f32
, etc...) - ベクトル型 (
vec2<f32>
,vec3<f32>
, etc...) - 行列型 (
mat2x2<f32>
,mat3x3<f32>
, etc...) - 要素の型が constructible な固定長配列型 (
array<f32, 3>
, etc...) - メンバーの型が全て constructible な構造体型
const data = Data(1.0, 2.0);
原子性 (atomicity)
uniform
, storage
, workgroup
は並列実行において共有されている。
uniform
は読み取り専用なので問題は起こらないが、storage
と workgroup
は書き込みも可能であるため、競合に注意する必要がある。
atomic<T>
は i32
と u32
に対してのみ使える。
atomic<T>
は workgroup
と storage
のアドレス空間の変数でのみ使える。
var<workgroup> counter: atomic<u32>;
atomic<T>
は constructible ではないため、以下の状況では直接使用できない。
- expression に書けない
- 関数の引数、戻り値に書けない
- 変数に代入できない
- 変数の初期化式に書けない
ビルトイン関数でのみ操作可能で、atomicLoad
と atomicStore
で読み書きできる。
他にも atomicAdd
, atomicMin
, atomicMax
, atomicAnd
, atomicOr
, atomicXor
などがある。
fn atomicLoad(atomic_ptr: ptr<AS, atomic<T>, read_write>) -> T fn atomicStore(atomic_ptr: ptr<AS, atomic<T>, read_write>, v: T)
ポインタ (pointer)
ポインタは ptr<AS, T, AM>
で表される。
AS
はアドレス空間 (address spaces)uniform
storage
workgroup
private
function
handle
T
はポインターが指す型AM
はアクセスモード。省略可能read
(default)read_write
アロー演算子 a->b
はない。カッコで囲んで (*a).b
のように書く。
ユーザー定義関数の引数にポインタを使う時の制約
- アドレス空間は
function
かprivate
でなければならない - 変数全体へのポインタのみ使える。複合変数の一部のみのアドレスを渡すことはできない
struct Data { aaa: u32, bbb: u32, } var<private> data: Data; fn foo() { //bar(&data.aaa); // Error: 複合変数の一部のみのアドレスを渡すことはできない }
式 (expression)
三つの評価フェーズがある。
- Shader creation time
- Pipeline creation time
- Shader execution time
Shader creation time
const-expressions の値を確定します。
- リテラル
@const
な関数const
宣言された値
Pipeline creation time
override-expressions の値を確定します。
override
宣言された値GPUProgrammableStage.constants
からの値
Shader execution time
runtime-expressions の値を確定します。
let
やvar
宣言された値- 全ての呼び出し関数
- 変数値
- 変数への参照やポインタ
リテラル
サフィックスのないリテラルは abstract-int か abstract-float になり、具体的な型付きの変数に束縛される時に型が決まる。
サフィックス | 型 | 例 |
---|---|---|
u |
u32 |
1u |
i |
i32 |
1i |
f |
f32 |
1.0f |
@const
ビルトイン関数
radians(x: T) -> T
const_assert(x: bool)
const x = 10; const_assert(x == 10); // false ならコンパイルエラー
制御フロー
if
文
if (condition) { ... }
または
if condition { ... }
switch
文
switch (condition) { ... }
または
switch condition { ... }
let a = 4; switch a { case 1, 2, 3: { } default: { } }
for
文
for(var i = 0; i < 10; i++) { }
(C#) オブジェクトの破棄の可否を型で制限する
Dispose による破棄
C# で明示的に破棄が必要なオブジェクトは、一般的に IDisposable
インターフェースによって破棄メソッドを提供します。
public sealed class MyObject : IDisposable { public void Dispose() { // 破棄処理 } }
ところが C# の Dispose パターンには欠点があり、誰が破棄を呼ぶかの責任の所在を明確にできないのです。
もっとも有名なのは Stream
ですね。
public void UseStream(Stream stream) { // stream を使う var buffer = new byte[10]; stream.Read(buffer.AsSpan()); // ここで Dispose してもいい? // 呼び出し元でまだ stream を使うから Dispose したらダメ? stream.Dispose(); }
Stream
を使って何かをした後、自分で Stream
を破棄してしまうべきか、呼び出し元でまだ使うため破棄してはいけないのかがわかりません。
コメントで「メソッド内で破棄しないでください」というように書いておいてお願いするしかないです。そして人間は愚かなのでお願いを破るミスを犯します。
失敗する可能性のあるものはいつか必ず失敗するのです。
型で静的に破棄の可否を制限する
すいません、先に言っておきます。以下で説明する方法では Stream
のような既存の IDisposable
に対して破棄の可否を制限することはできません。ごめんなさい!
気を取り直して続けます。
以下のような Own<T>
という型と、破棄が必要な型 MyObject
を作ってみます。
public readonly struct Own<T> : IDisposable { private readonly T _value; private readonly Action<T>? _release; public bool IsNone => _release == null; public T Value { get { if (IsNone) { throw new InvalidOperationException(); } return _value; } } public Own(T value, Action<T> release) { _value = value ?? throw new ArgumentNullException(nameof(value)); _release = release ?? throw new ArgumentNullException(nameof(release)); } public void Dispose() => _release?.Invoke(_value); // IEquatable 実装は省略... } // 破棄が必要な型 public sealed class MyObject { private MyObject() { } ~MyObject() => _release(this); private static readonly Action<MyObject> _release = static self => { // 破棄処理 }; public static Own<MyObject> Create() { return new Own<MyObject>(new MyObject(), _release); } }
説明用なのでいろいろときちんとした実装は省略しています。
順番に説明していきましょう。
まず、MyObject
はコンストラクタが private になっており、かわりに Create
という static メソッドがあります。
この Create
メソッドは MyObject
ではなく Own<MyObject>
を返しています。
また、よく見ると MyObject は IDisposable
を実装していませんね?MyObject
自身は Dispose 出来ず、Own<MyObject>
に破棄処理を移譲します。
ここで、Own<T>
型は T
を破棄する権利を持っていることを表し、破棄する責任を負っています。
T
は破棄が必要なオブジェクトですが T
自身にその責任はありません。データとしての役割と破棄の責任を T
と Own<T>
に分割しているのです。
実際に例を出してみます。 これを以下のように使います。
void Main() { Own<MyObject> obj = MyObject.Create(); UseObject1(obj.Value); UseObject2(obj); } void UseObject1(MyObject obj) { // MyObject を使う。 // 破棄出来ない (== 破棄しなくてよい) } void UseObject2(Own<MyObject> obj) { // MyObject を使う。 // ここで破棄する (== 破棄する責任があることが型で明示されている) obj.Dispose(); }
上の例で UseObject1
メソッドは MyObject
を受け取っています。これは使った後に破棄する必要はありません。
というより破棄できません。そういう作りになっています。
次の UseObject2
メソッドは Own<MyObject>
を受け取っています。つまり、使った後に破棄する責任があること意味します。
機能と欠点
先ほどの Own<T>
は二つの機能があります。
- 破棄してはいけない場面で破棄できないようにする
- 破棄責任を負っていることを型で明示する
1つめは確実に機能します。勝手に破棄されては困る場面では Own<T>
ではなく T
を渡すと、T
は自身を破棄できないため安全です。
2つめは確実ではないですがないよりは圧倒的にマシです。破棄責任があることが型で明示されているので、迷わず破棄していいのです。
Dispose
するのを忘れるとどうしようもないのですが、C# ではこれを強制する手段はありません。
とはいえ、破棄されては困る場所では破棄させないという目的は達せられます。破棄忘れについては C# ではファイナライザが責任を持ちます。
IDisposable
について
前項の最初に言った通り、Stream
など既存の IDisposable
について前述の Own<T>
で上手く扱うことはできません。
IDisposable
の破棄責任の所在があいまいなのは C# が持つどうしようもない欠点です。
とはいえ欠点はあれど、大きな問題にならないのは理由があります。
最近のパフォーマンス重視の C# はともかく、破棄が必要なオブジェクトは基本的に class で実装され、class はファイナライザを持つことができます。 Dispose を忘れたとしてもファイナライザが責任を負うため、正しく実装されたクラスではメモリリークは起こりません。ただし、ファイナライザは Dispose より高コストでパフォーマンス的に不利なため、最後のセーフティです。
また、IDisposable
は複数回 Dispose を呼んでも問題がないように実装するというガイドラインがあります。
これによって、2回以上 Dispose しても問題にならず、最悪の場合 Dispose を忘れても問題はないという仕組みです。(だから誰が破棄するかの責任があいまいなのです……)
よって、問題になるのはまだ破棄してはいけないオブジェクトを誤って破棄できてしまうことです。本記事で解決したかった問題はこれですね。
(C#) interface の静的仮想メンバーでジェネリックコンストラクタ
interface の静的仮想メンバー
C# 11 から interface に静的仮想メンバーを持たせられるようになりました。これは別名 "Generic Math" と呼ばれており、数値の演算がジェネリクスを通して呼べるようになりました。
しかし、本記事では interface の静的仮想メンバーがもたらす別の使い方を解説します。 主たる動機である Generic Math の方の解説はググってください、ここでは説明しません。
「interface の静的仮想メンバー」が何かを簡単に言うと、interface に staitc メソッドを定義することで、そのインターフェースを継承した具象型に static メソッドの実装を強制させることができるのです。
public interface IFoo { static abstract void Foo(); }
上記のように interface に静的メソッドを定義でき、この IFoo
を継承した型はかならず static void Foo()
を実装するようにできるということです。
ジェネリックコンストラクタ
本題に入る前に、ジェネリックスなメソッドの new()
制約について確認します。
public static void Bar<T>() where T : new() { var obj = new T(); // ... 何か処理 }
上記の Bar
メソッドにはジェネリックス引数 T
があり、その制約として new()
制約があります。
これは、型 T
が引数のないコンストラクタを持っていることが必要で、この制約によってメソッド内で new T()
を使うことができます。
しかし、この new()
制約は引数ありの制約、例えば where T : new(string)
のようにすることができません。
また、new()
制約は内部的にはリフレクションを使ってインスタンスを生成するように作られており、微々たるものですがパフォーマンスが良くないことも知られています。
これを、C# 11 からの interface の静的仮想メンバーをつかって解決します。
public interface IConstruct<TSelf, T> { static abstract TSelf New(in T item); } public class Dog : IConstruct<Dog, string> { public string Name { get; } public Dog(string name) => Name = name; public static Dog New(in string item) => new Dog(item); } public class Person : IConstruct<Person, string> { public string Name { get; } public Person(string name) => Name = name; public static Person New(in string item) => new Person(item); }
上記のように IConstruct
インターフェースを定義して、それを継承した型 Dog
と Person
を作ってみました。
そしてこれを以下のように使います。
public static T Something<T>() where T : IConstruct<T, string> { string arg = Console.ReadLine() ?? ""; T obj = T.New(arg); return obj; } // ----------------------- // インスタンス生成を伴う処理をジェネリックに書けた var dog = Something<Dog>(); var person = Something<Person>();
今までは実現できなかったジェネリックスによる引数ありのインスタンス生成が可能になりました。リフレクションも使っていません。
C# 10 以前ではリフレクションを使ったり DI (依存性注入) 的なことをしないとどう頑張ってもこれが書けなかったのですが、C# 11 ならできますね。
余談
上記の解説用のコード、ものすごく Rust っぽくないですか?(半分わざと似せてるのもあるんですが)
IConstruct<TSelf, T>
はインターフェース内で自分自身の具象型にアクセスするために TSelf
を必要としているのですが、これは Rust の trait における Self
です。
先ほどの IConstruct<TSelf, T>
は Rust の From<T>
trait とやってることは完全に同じですね。
Rust で trait 境界から関数を呼んでもコンパイル時に解決されてゼロコストなのと同様、さきほどの C# のコードも動的なコストはありません。 むしろオーバーヘッドがあると、Generic Math な使い方で四則演算などの超軽量な処理を抽象化できません。遅すぎて使い物にならなくなってしまいます。
(C#) 文字列補間されていない文字列補間を取得する
明日使えない小ネタ記事です。
C# 10 から文字列補間の $""
構文が独自拡張可能になっています。
これはパターンベースになっており、特定の条件を満たす型を書くことで動作します。
本記事ではまともな使い方やメリットについては解説しません。 まともな使い方や実装方法は公式ドキュメントを見てください。
これをハックして、文字列補間前の文字列を取得してみます。 以下のような型を作ります。
using System.Runtime.CompilerServices; [InterpolatedStringHandler] public readonly struct NotInterpolation { public string Value { get; } public NotInterpolation( int literalLength, int formattedCount, [CallerArgumentExpression("literalLength")] string expr = "") { Value = expr; } public void AppendLiteral(string s) { } public void AppendFormatted<T>(T t) { } public override string ToString() => Value; }
そして以下のコードを実行。
var num = 10; NotInterpolation str = $"The number is {num}."; Console.WriteLine(str.Value);
実行結果の出力がこちら
$"The number is {num}."
文字列ハンドラーのコンストラクタの第一引数に対して CallerArgumentExpression
属性で引数表現を取ると、なんとびっくり文字列補間前の文字列表現が文字列で取れるんです。
パターンベースなので、コンストラクタに勝手に引数を追加してもデフォルト引数があればコンパイルできちゃうんです。
しかも、この文字列表現はコンパイル時に確定しておりハンドラー自体は構造体なので最適化されて全てが消えるため、実行時コストは0です。嬉しい~~~。
何に使えるんだ……これ?
(C#) Obsolete によるコンパイルエラーを無視する
おことわり
ハック記事です。非推奨 API は呼び出すべきではありません。
非推奨属性 (Obsolete
)
System.ObsoleteAttribute
はメソッドやクラスが非推奨であることをマークする属性です。引数の有無によって警告にするかコンパイルエラーにするかを選べます。
メソッドにつければそのメソッドのみ、クラスにつけるとクラス全体が非推奨になります。
// 引数無しは警告 [Obsolete] public void Foo1() { } // メッセージ付き警告 [Obsolete("Use another method because ...")] public void Foo2() { } // 第二引数を true にするとコンパイルエラー [Obsolete("Use another method because ...", true)] public void Foo3() { }
この非推奨マークはコンパイラへの注釈であって、コンパイル結果には影響しません。 つまり、実際にはコンパイルは行われているため、たとえコンパイルエラーとしてマークされていても通常ではない方法で呼び出せます。
Reflection
リフレクションによる呼び出しは Obsolete
属性を無視します。たとえコンパイルエラーに指定されていても何も問題なく呼び出せます。
// 実行できる typeof(Foo).GetMethod("Bar")!.Invoke(null, null); public class Foo { [Obsolete("obsolete", true)] public static void Bar() { Console.WriteLine("aaa"); } }
Obsolete
からの呼び出し
Obsolete
属性のついたメソッド・クラスは、同じく Obsolete
属性のついたものからは警告・エラーなく呼び出せるという仕組みになっています。
最終的に有効なコードから非推奨APIを呼び出そうとする部分で警告・エラーが出るため、一般的には問題なさそうに見えます。
ところが、この連鎖的な呼び出しのチェックは警告なのかコンパイルエラーなのかを区別しません。
つまり、有効なコード→非推奨 API (警告)→非推奨 API (コンパイルエラー) の順で呼び出しを書くと、一つ目の呼び出し箇所で警告は出ますがコンパイルエラーにはならずに二つ目の非推奨APIを実行できます。
ついでにコンパイルエラーは抑制できませんが、警告なら抑制できますね。(#pragma warning disable 0618
)
// Bar2 の呼び出しに対する警告は出るが、間接的に Bar を呼び出して実行できる Foo.Bar2(); public class Foo { [Obsolete("obsolete", true)] public static void Bar() { Console.WriteLine("aaa"); } [Obsolete("obsolete")] public static void Bar2() { Bar(); } }
Module Initializer
ModuleInitializer
としてマークされたメソッドは Obsolete
であってもエラーにならず、モジュール初期化処理として機能できます。
非推奨を回避するという使い方ではないですが参考までに。
詳細はこちら。
おまけ 拡張メソッドのクラス
拡張メソッドを記述しているクラスは、クラスに Obsolete
属性をつけていても拡張メソッドは生きています。
非推奨にしたい場合は各拡張メソッドに属性をつける必要があるので注意。
var foo = new Foo(); // 拡張メソッドの記述クラスがコンパイルエラーに指定されていても、 // 拡張メソッドはエラーにならない foo.Bar(); // 拡張じゃない呼び出しをしている場合はエラー FooExtensions.Bar(foo) public class Foo { } [Obsolete("obsolete", true)] public static class FooExtensions { public static void Bar(this Foo foo) { Console.WriteLine("aaa"); } }
さいごに
非推奨 API は呼び出すべきではありません。