ネコのために鐘は鳴る

寺院に住み着くパソコ〇好き

(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 自身にその責任はありません。データとしての役割と破棄の責任を TOwn<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 を忘れても問題はないという仕組みです。(だから誰が破棄するかの責任があいまいなのです……)

よって、問題になるのはまだ破棄してはいけないオブジェクトを誤って破棄できてしまうことです。本記事で解決したかった問題はこれですね。