tech.guitarrapc.cóm

Technical updates

Target-typed newと暗黙的型変換を期待するAPI

C# 91から利用できるTarget-typed newは使い方によっては非常に便利です。 ただ、ライブラリのAPI設定次第で使いたいときに使えないというシーンもあるのでメモです。

暗黙的型変換を期待するAPIではTarget-typed Newは使えない

暗黙的型変換を前提としたInput<T>を使うAPIではTarget-typed newは使えずCS8752でコンパイルエラーになります。

Input<T>Tに対する暗黙的型変換を持っているものの、new()が期待する型はTではなくInput<T>だからです。

// 省略
var wafrule = new Rule("wafrule", new()
{
    // 省略
    Predicates = [
        // コンパイルエラー
        // CS8752: The type 'Input<rulePreficateArgs>[]' may not be used as the target type of new()
        new ()
        {
            DataId = ipset.Id,
            Negated = false,
            Type = "IPMatch",
        },
    ],
});

image

暗黙的型変換を前提としたAPIではTarget-typed newは使えないということで割り切ってます。

Pulumi C#で頻出のAPIパターン

PulumiのAPIについて補足します。Pulumi C#は対象のクラウドリソースをC#コンストラクターの生成で表現し、そのコンストラクターの引数にリソースの設定を渡します。

var resource = new TResource("name", new TResourceArgs
{
    Property = "value"
});

リソースごとにTResourceArgs型は違いますが、Pulumiでリソース作成するときはいつも同じAPIなため型を意識しないことから、Target-typed newを使うと設定に注目2できます。

// new()で十分わかる
var bucket = new Bucket("b", new()
{
    BucketName = "my-tf-test-bucket",
    Acl = CannedAcl.Private,
});

PulumiのInput

PulumiはTerraformやCDKと同様にクラウドリソースなどを定義するためのDSLです。ということは、まだリソースがなくてもそのリソースがあるように振る舞う必要があり、それを表現するためInputクラスがあります。クラス定義を抜粋すると次のように、TOutput<T>を受けとることを期待したAPIです。

// https://github.com/pulumi/pulumi-dotnet/blob/05c1cfa01bf75fd2794a94f26c337e088944b3f8/sdk/Pulumi/Core/Input.cs
public class Input<T> : IInput
{
    public static implicit operator Input<T>(T value)
    {
        return Output.Create(value);
    }

    public static implicit operator Input<T>(Output<T> value)
    {
        return new Input<T>(value);
    }

    public static implicit operator Output<T>(Input<T> input)
    {
        return input._outputValue;
    }
}

さて、各リソースの引数TResourceArgsの各プロパティはInput<T>で定義されており、リソースの作成結果はOutput<T>です。Input<T>Output<T>の暗黙的型変換を持っており、型を活用してリソース間の依存関係グラフを構築・実行制御します。

public static implicit operator Output<T>(Input<T> input)

例えばWafv2のIPSetで用いるIpSetArgsの型定義は次の通りです。NameIpSetDescriptors共にInput<T>(とリスト版であるInputList<T>)で定義されています。

public sealed class IpSetArgs : ResourceArgs
{
  public Input<string>? Name { get; set; }
  public InputList<IpSetIpSetDescriptorArgs> IpSetDescriptors
}

Input<T>Tに対する暗黙的型変換を持っているのでシンプルに書けます。

var ipset = new IpSet("foo-ipset", new()
{
    // Input<T>はTを暗黙的に変換できる
    Name = "FooIPSet",
    // InputList<T>の要素はInput<T>なので、これも暗黙的に変換できる
    IpSetDescriptors = [
        new IpSetIpSetDescriptorArgs
        {
            Type = "IPV4",
            Value = "192.0.7.0/24",
        },
    ],
});

とはいえ、通常はリソース依存関係を自動解決させるためにもリソースの作成結果Output<T>を使います。例えば先に作ったIpSetのIdを使ってWafv2のRuleを作成するときは次のようにかけます。Terraformと同じ感じですね。

var ipset = new IpSet("foo-ipset", new()
{
    // なにか作ったとする
});

var wafrule = new Rule("wafrule", new()
{
    Name = "rule1",
    MetricName = "rule1",
    Predicates = [
        new RulePredicateArgs
        {
            // ココに注目!リソースの結果であるId (Output<T>) を渡すことでリソース依存関係を自動解決する
            DataId = ipset.Id,
            Negated = false,
            Type = "IPMatch",
        },
    ],
});

Input<T>Output<T>という型表現で、リソースの依存関係を解決、まだ存在しないリソースに対応している。これがPulumiにおけるリソースの作成の基本です。

以上、本題の背景にあるInput<T>Output<T>について補足でした。

Target-typed newは便利

ここからは個人的なTarget-typed newの使いどころや好みです。

Target-typed newは型が既知の場合、コンストラクターの型指定を省略できる構文です。varを使った型推論と似ていますが、varが使えないシーンで利用できるのが特徴です。例えばフィールドはvarが使えませんが、型は自明なのでTarget-typed newを使ってDictionaryのオブジェクト初期化子で使うのは自明かつ便利ですね。

// 通常の書き方 (フィールド)
private Dictionary<string, List<int>> field = new Dictionary<string, List<int>>() {
    { "item1", new() { 1, 2, 3 } }
};

// Target-typed newを使う (.NET 5+ = C# 9.0+)
private Dictionary<string, List<int>> field = new() {
    { "item1", new() { 1, 2, 3 } }
};

// コンパイルエラー: フィールドでvarは使えない
private var field = new Dictionary<string, List<int>>() {
    { "item1", new() { 1, 2, 3 } }
};

メソッドボディではvarが使えますが、オブジェクト初期化子で引き続き便利ですね。ただvarが使えるのにTarget-typed newを使うのは型推論をどっちベースでやるのか指針があるとよさそうです。

// 通常の書き方 (メソッドボディ)
var field = new Dictionary<string, List<int>>() {
    { "item1", new List<int>() { 1, 2, 3 } },
};

// Target-typed newを使う (.NET 5+ = C# 9.0+)
var field = new Dictionary<string, List<int>>() {
    { "item1", new() { 1, 2, 3 } }
};

// フィールドだと違和感ないが、メソッドボディで全部Target-typed newに寄せるのはどうだろう?
Dictionary<string, List<int>> field = new() {
    { "item1", new() { 1, 2, 3 } }
};

コンストラクターやメソッドの引数でも、使用方法から型を推測できる場合は型指定を省略できます。 Cysharp/Claudiaがうまい例で、JSON的な設定を組み立てるとき型は重要度が低くTarget-typed newは便利です。

using Claudia;

var message = await anthropic.Messages.CreateAsync(new()
{
    Model = "claude-3-5-sonnet-20240620", // you can use Claudia.Models.Claude3_5Sonnet string constant
    MaxTokens = 1024,
    Messages = [new() { Role = "user", Content = "Hello, Claude" }]
});

AWS SDKのS3はTRequest型のリクエストを受け取りTResponse型のレスポンスを返すAPIなので、リクエストの型は設定に過ぎずTarget-typed newが便利です。

// クライアントのリクエストは、必ずTRequest型を使うとわかっているので冗長と判断できる
using var response = await client.GetObjectAsync(new GetObjectRequest
{
    BucketName = bucketName,
    Key = objectName,
};);

// Target-typed newで十分
using var response = await client.GetObjectAsync(new ()
{
    BucketName = bucketName,
    Key = objectName,
};);

なお、型が自明でない場合はコンパイルエラーが出ます。

// コンパイルエラー: new()がJsonSerializerOptionsかJsonTypeInfoか自明でない
// CS0121 The call is ambiguous between the following methods or properties: 'JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions?)' and 'JsonSerializer.Serialize<TValue>(TValue, JsonTypeInfo<TValue>)'
JsonSerializer.Serialize(new Dictionary<string, string>
{
    { "key", "value" }
}, new() { WriteIndented = true });

個人的にはTarget-typed newを使うシーンは以下のようなケースが多いです。

  • フィールド (左辺が重要でTarget-typed newでしか型推論できない)
  • オブジェクト初期化子 (型は自明でTarget-typed newでしか型推論できない)
  • 設定系の型は読みやすさに寄与しないケースが多い (型の重要度が低く、設定はシンプルであるほど読みやすくなる)

Target-typed newが使いにくいシーン

Target-typed newが出たときにも指摘がありましたが、Target-typed newはあまり読みやくないという指摘をちらちら見かけます。私も割と同意するところです。

newのケース

私は、以下のようなnewのケースはvarの方が読みやすく感じます。ただこれはどっちでも自明なのでどうでもいいです。

// varは、左辺に注目すると型がわからないという指摘がある
var list = new List<string>();

// Target-typed newは、右辺に注目すると型がわからないという指摘がある
List<string> list2 = new();

コンストラクター引数のケース

インスタンス化するときにTarge-typed newを使うのも、限定的なシーン以外では読みにくくなっていると感じます。5年経った今も、型指定したほうが読みやすいかな?

// 何をインスタンス化しようとしているか、コンストラクタ引数があるのは冗長という指摘
var outer = new MyOuter(new List<string>());

// newの入れ子は何をインスタンス化しようとしているか不明確という指摘
MyOuter outer2 = new(new());

// コンストラクターの型を指定すれば読みやすい...?
MyOuter outer3 = new(new List<string>());

public class MyOuter
{
    public MyOuter(List<string> items) {  }
}

向いていない例として、連鎖的な依存を持つ複雑なコンストラクターは見た目もやばく、これを修正したくないですね。

// 手に負えない...
new(new(new(),new()))

メソッド引数のケース

コンストラクター引数で感じたことはメソッド引数でも同様で、限定的なシーン以外では読みにくくなりやすいと感じます。

// 引数の型が冗長という指摘もあるが...?
var foo = Foo(new List<string>());

// メソッドの事情を知らないとnew()が適切か判断できない
var foo2 = Foo(new());

List<string> Foo(List<string> list) => list;

これも極端な話をあげると、new()が連続するのは厳しいと感じます。

// 手に負えない...
Foo(new(), 1, 2, new(), new())

右辺で簡単に推測できるLINQもvar一択ではないでしょうか。

// varで十分自明
var keyValuePairs = parameters.Select(p => KeyValuePair.Create(p.Key, p.Value));

// Target-typed newが冗長すぎる
IEnumerable<KeyValuePair<string, string>> keyValuePairs = parameters.Select(p => KeyValuePair.Create(p.Key, p.Value));

こういった、使い方に対する指針はC#コミュニティを含めてまだ定まっていない認識なのですがどうなんですかね。

AI前提で好みやスタイルは変わっていくか

こういうスタイルは、多かれ少なかれ各自に好みはありますが、今後は、AIが生成するコードによって「ある程度個人の好みは割り切っていく」ことになりそうだと感じます。

現行の生成AIが出力するC#コードは型を明示varが混じることも多いです。今のところTarget-typed newを使ったコードはあまり出力されていませんが、今後は出力されることも増えるでしょう。そうなったときに、varなのかTarget-typed newなのかにこだわるのは違うのかなと感じざるを得ません。

プロンプトやルール、あるいは.editorconfigなどで出力するコードフォーマットを指定することが主流になるのかはわかりませんが、コーディングスタイルへの意識も変わりそうだなと予想しています。

まとめ

Target-typed newは便利。暗黙的型変換を前提としたAPIでは使いにくかったり、連鎖した引数で乱用するのは違うと感じますが、使いどころは確実に存在しています。C# 9がリリースして数年たち、Target-typed newの利用シーンは着実に増えているように感じます。設定やテンプレパターンではTarget-typed newが使いやすいので、今後も使いどころを見極めていきたいですね。

AI前提でのスタイルの変化があるかも楽しみです。

参考


  1. 2020年11月にリリース
  2. 型がノイズなので、むしろ省略したい。