インターフェース使っててハマりそうなところをつらつらと

  • 6
    Like
  • 0
    Comment
More than 1 year has passed since last update.

はじめに

delphiのインターフェース型を使ってみて、ハマったことや、注意すべき点をまとめたいと思います。
内容については、@pikさんの去年のアドベントカレンダーと丸かぶりだけどキニシナイ。

Interface型の使い方おさらい

interface節にインターフェース型を定義します(まぎらわしい)

type
    IHoge = interface
        procedure Foo;
        function Bar: integer;
    end;

delphiの慣例として、インターフェース型名はI〜で始まっているため、この慣例にあわせておくと混乱を避けることができるでしょう
(あくまで慣例なので、アプリケーションハンガリアンに親を殺された等の理由で使用したくなければそれでも何ら問題はないです)

インターフェースを実装した派生クラスを定義します。

親クラスに後にカンマ区切りで実装するインターフェース型を並べます(下記コード参照)

インターフェース型は、基底として必ずIInterface型を継承しているため、このインターフェース型の全てのメソッドを実装してあげなければなりません。

しかし、TInterfacedObjectやTComponentであれば、デフォルトの実装が用意されているため、通常はこれらを親クラスとしておけばよいでしょう。

type
    THoge = class(TInterfacedObject, IInterface)
        procedure Foo;
        function Bar: integer;  
    end;

implementation節に実装を書きます。

Interface型でできること1 - 参照カウンティング -

参照カウンティングとは、オブジェクトが変数に渡されるとカウントアップし、
その変数の値が別のオブジェクトに付け替えられたり、不要になったときカウントダウンし、
カウントが0になると、自動的にオブジェクトが破棄される仕組みです。
簡易的なガーベッジコレクタと思ってもらえば分かりやすいかと。

関数や手続きの中だけでオブジェクトを使用する場合、

var
    obj: THoge;
begin
    obj := THoge.Create;
    try
        ...
    finally
        obj.Free;
    end;
end;

のように、try-finallyで囲って、破棄を保証するような記述をするかと思います。
しかし毎回書くのがめんどい。

ここで、生成したオブジェクトをインターフェース型の変数で受け取るようにすると、

var
    obj: IHoge;
begin
    obj := THoge.Create; // THoge = class(TInterfacedObject, IHoge);

    ...
end;

のように、明示的にオブジェクトを破棄する必要がなくなります。
これは、関数や手続きから抜ける時、ローカル変数が無効となるため自動的にカウントダウン仕掛けが、
delphiのランタイムに組み込まれているからです。

参照カウンティングを使う上での注意

参照カウンティングを効かせるためには、インターフェス型の変数に入れる必要があります。
クラス型の変数ではカウントアップしないため要注意です。

var
    obj: THoge;
    intf: IHoge;
begin
    obj := THoge.Create; // 参照カウンティングされない
    intf := THoge.Create; // OK.

    ...
end;

意図的に参照カウンティングを避けたいときにクラス型の変数で保持することもあり、必ずしもインターフェース型で受けなければならないというわけではないです。

二重破棄の問題

また、オブジェクトを使用していて、しばしば問題として二重破棄があります。

例えば、

var
    obj1, obj2: TParent;
    obj3: TChild;
begin
    ...

    obj1.SetChild(child);
    obj2.SetChild(child);

    ...

    obj1.Free; 
    obj2.Free;
end;

のようなコードがあったとき、もしTParentのデストラクタの中で、

destructor TParent.Dstroy;
begin
    FChild.Free;
end;

としていた場合、obj1は問題なく破棄されますが、obj2は保持するTChildのインスタンス変数が既に破棄されているため、
二重破棄となり、たいていアクセスバイオレーションを発生してアプリが死にます。
最悪、アクセスバイオレーションが出なくても、メモリが破壊されているため、全く関係ないずっと先で謎のエラーを起して死ぬ羽目になります。

ここで、TParentがTChild型の変数をインターフェース型として持っていた場合、

TParentのデストラクタを

destructor TParent.Dstroy;
begin
    FChild := nil;
end;

としておくことで、

var
    obj1, obj2: TParent;
    obj3: TChild;
begin
    ...

    obj1.SetChild(child); // +1 (1)
    obj2.SetChild(child); // +1 (2)

    ...

    obj1.Free; // -1 (1)
    obj2.Free; // -1 (0)
end;

obj1の破棄では、カウントが残っているため、childの破棄は先送りにされ、obj2のタイミングで破棄され、
手軽に二重破棄を避けることができます。

補足

オブジェクトとの対比のため、親側のデストラクタでnilを割り当てていますが、実はこれ不要です。
デストラクタの後処理で、delphiランタイムがnil代入を代わりにやってくれ、結果ちゃんと子側のデストラクタが呼ばれます。

つまり、インターフェース型のフィールドを破棄するだけなら、デストラクタをリストラ対象にさせることができます。

循環参照の問題

以下のようなコードについて、

type
    THoge = class(TInterfacedObject, IHoge)
        FUge: IUge;
    end;

    TUge = class(TInterfacedObject, IUge)
        FHoge: IHoge;
    end;

....

var
    hoge: IHoge;
    uge: IUge;
begin
    ...

    hoge.FUge := uge;
    uge.FHoge := hoge;

    ...
end;

hogeを自動破棄するためには、ugeが保持するFHogeを無効にする必要があり、
ugeを自動破棄するためには、hogeがほじするFUgeを無効にする必要があり、堂々巡りとなり結果メモリリークの原因となってしまいます。

回避するすべはないため、インターフェースを使用する場合は常に気配っておく必要があります。

モバイル用ターゲットでの循環参照

基本、循環参照はどうすることもできないですが、XE3以降のモバイル用ターゲットで使用されるコンパイラ(NEXTGENコンパイラ)には、回避策が用意されています。

一方のフィールド定義でWeakアノテーションを付けることで、ランタイムが空気を読んでカウントアップしないようにしてくれます。

    TUge = class(TInterfacedObject, IUge)
        [Weak]
        FHoge: IHoge;
    end;

Delphi モバイル コンパイラでの自動参照カウント

インターフェース型でできること2 - メソッド実装の強制 -

クラスがインターフェース型を実装する場合、インターフェース型にて宣言された関数、手続きを実装する必要があります
(ただしabstractにして、下位の派生クラスに回す場合は除く)。
未実装の抽象メソッドの場合警告止まりなのに対し、インターフェース型は実装し忘れがあるとコンパイルエラーにしてくれます。

インターフェースメソッドの可視性

JavaやC#の場合インターフェース型のメソッドは、必ずpublicに置く必要がありますが、delphiの場合、privateだろうがprotectedだろうがどこにでも置くことができます。
インターフェース型の変数で受ければ、クラスの可視性には左右されず、宣言された関数、手続きに触ることができます。

この仕様を利用し、クラス型の変数からは隠蔽し、インターフェース型からのみ触らせるような使い方ができます。
自前のライブラリを作る場合に、この仕様が生きてくるかも(LiveBindingでは結構多用している模様)。

また、インターフェース型でプロパティを宣言する場合、getterとsetterはメソッドとして用意する必要があります(フィールドを持てないため)

インターフェース型の可視性を利用して、setterとgetterをprivateに置くことで、直接呼べなくさせることはできるでしょう。
もっともインターフェース型からでは呼べてしまいますが(最近のdelphi IDEは補完候補から外してくれるので、もはや気にする必要はないのかも)

インターフェース型でできること3 - 明示的実装 -

異なるインターフェース型で異なる目的の同じ名称のメソッド宣言されていても、クラスでは同じ宣言のメソッドを複数置けないため、そのまままでは受け取るインターフェース型で呼ばれるメソッドを切り替えることはできません。

例えば、

type
    IResponseHeaderReader = interface
        function Read(int n): string; // ヘッダを読み込む
    end;
    IResponseBodyReader = interface
        function Read(int n): string; // 本文を読み込む
    end;

    THttpReader class(TInterfacedObject, ResponseHeaderReader, ResponseBodyReader)
        function Read(int n): string; // どっちのRead?
    end;

この場合、インターフェース型の明示的実装を行うことで、同じ識別子にインターフェース型ごとの実装を与えることができます。

type
    THttpReader = class(TInterfacedObject, IResponseHeaderReader, IResponseBodyReader)
    private
        function ReadHeader(n: integer): string;
        function ReadBody(n: integer): string;
    protected
        function IResponseHeaderReader.Read(n: integer) = ReadHeader; // (a)
        function IResponseBodyReader.Read(n: integer) = ReadBody; // (a)
    end;

(a)と書いたコメント行のように、

function インターフェース型名.メソッド名(引数) = 実際のメソッド名(メソッド解決句)

と記述しておき、インターフェース型のメソッドではなく、解決句として宣言したメソッドだけを実装します。
上のコード例ではfunctionについて書いていますが、procedureの場合も同様です。

var
    reader: THttpReader;
    headerReader: IResponseHeaderReader;
    bodyReader: IResponseBodyReader;
begin
    reader := THttpReader.Create;

    headerReader := reader;
    headerReader.Read(1); // ReadHeaderが呼ばれる

    bodyReader := reader;
    bodyReader.Read(1); // ReadBodyが呼ばれる
end;

インターフェース型でできること4 - 実装の委任(委譲)-

ときおり、以下のクラスのように、

type
    THoge = class
    private
        FImpl: IHoge;
    public
        procedure P1;
        function F1: integer;
    end;

procedure THoge.P1;
begin
    FImpl.P1;
end;

function THoge.F1: integer;
begin
    Result := FImpl.F1;
end;

そのままたらい回すようなコードを書くことがあると思います(AdapterやFacard等)。
数メソッド程度なら、一つ一つ埋めていってもまだなんとかなりますが、もっと数が増えてくるとかなり嫌になるものです。

この場合、実装の委任を行うことで楽することができます。
実装の委任は、

  1. たらい回し先のインターフェース型を実装させ、たらい回し先となるフィールドを取得するプロパティを用意します。
type
    THoge = class(TInterfacedObject, IHoge)
    property
        FImpl: IHoge;
    public
        property Impl: IHoge read FImpl;
    end;
  1. そのプロパティに以下のようなおまじないを書き加えます
property Impl: IHoge read FImpl implements IHoge;

この書き足した記述により、THogeの実装すべきメソッドをこのプロパティが代わりに受け持ってくれるようになります。

実装の委任を使う上の注意点

実装の委任を使う場合、インターフェース型の一部のメソッドだけ自前で実装し、残りをたらい回しさにすることはできません。

これは、

var
    hoge: IHoge;
begin
    hoge := THoge.Create;
end;

と書いたとき、実質的に

var
    hoge: IHoge;
begin
    hoge := THoge.Create.Impl;
end;

のように扱われ、自前で書いたコードが呼ばれることがないからです。

一部自前実装、一部たらい回しを行う場合は、残念ながら実装の委任をあきらめ、全て自前で記述するしかないです。

委任?

以前のマニュアルでは実装の委任と書かれていたのですが、XE7のdocwikiではデリゲート(委譲)に変えられてる。
今はもう委任とは言わない方がいいんですかねぇ?

インターフェース型でできないこと

インターフェース型で宣言できるのは、手続きやメソッド、プロパティのみで、フィールドはもちろん定数すらも宣言することはできません。

インターフェースとGUID

インターフェース型には以下のようにGUIDを付けることができます。
GUIDは、「Ctrl + Shift + Gキー」を押すことで、キャレット位置に生成してくれます。

type
    IHoge = interface
        ['{00000115-0000-0000-C000-000000000146}']
        ....
    end;

GUIDを付けることで、コンパイル時にインターフェース型と実装したクラス間を結びつけるマッピングが記録されます。

GUIDはいつ付けるべき?

マニュアル(docwiki)のDelphi言語ガイド > オブジェクト インターフェイスで出てくるサンプルはどれもGUIDが付け足されています。
なんかGUIDを付けなきゃいけないような気分にさせられるのですが、実際のところどうなんでしょうか?

結論としては使い方により変わってきます。

クラスが一つのインターフェース型しか実装しておらず、生成後直ちにインターフェース型の変数に入れてしまう場合、GUIDは不要(着いていても問題ない)。

一方、クラスが複数のインターフェース型を実装し、インターフェース間を行き来させる場合、GUIDが必要となるでしょう。
なぜなら、インターフェース型は明示的型変換や as による型変換を行えないからです。

即ち

type
    TMyClass = class(TInterfacedObject, IA, IB, IC)
        ....
    end;

のようなクラスがあったとして、

以下の記述では、コンパイルエラーとなってしまいます。

var
    a: IA;
    b1, b2: IB;
begin
    a := TMyClass.Create;
    b1 := IB(a); // NG コンパイルエラーになる
    b2 := a as IB; // NG コンパイルエラーになる
end;

インターフェース間の型変換を行いたい場合、SysUtils.Supports を使用します。
この関数の仕様は

  • 第一引数が、変換元
  • 第二引数が、変換先のGUIDを付けたインターフェース型
  • 第三引数が、変換後の格納先(オプション)
  • 変換できた場合、trueを返し、失敗した場合、falseを返す

となっています。

使い方は、

var
    a: IA;
    b1, b2: IB;
begin
    a := TMyClass.Create;

    if SysUtils.Supports(a, IB, b) then begin
        ... // bを使った処理
    end;
end;

となります。

もしGUIDを付けていないインターフェース型をSupports関数の第二引数に渡した場合、コンパイルエラーとして報告してくれます。
なので、始めはGUID無しでインターフェース型を宣言しておき、Supports関数を通す必要が出てきたとことでGUIDを付与してあげればいいのではないかと思います。

TComponent + IInterface

TComponent型もまた、TInterfacedObject同様IInterfaceのメソッドを実装しているため、インターフェース型を実装する際の親クラスとして使用することができます。

しかしTComponentでコンポーネントツリーを構成させているとき、コンポーネントのルートを破棄すると自動的に配下の要素も破棄されます。
参照カウンティングによって勝手に破棄されてしまうのはマズいため、TComponentでは参照カウンティングが無効化されています。

コンポーネントツリー外で、コンポーネントの要素をインターフェース型として保持されている場合、この無効化がときおり悪さをすることがあります。

その一つとして、コンポーネントツリーが先に破棄された場合、コンポーネントツリー外で参照カウンタを減らそうとするもインスタンスがないためアクセスバイオレーションが発生するというのが挙げられます。

これを避ける方法は、サブコンポーネントのプロパティの作成や、インターフェイスのプロパティの作成に従って、コンポーネントからの破棄通知を受け取れるようにすることです。

即ち、TComponentや実体がTComponentなインターフェース型をプロパティとして持つのであれば、所有側もTComponentにする必要があるということであり、結論コンポーネントツリーに入れなければならないということです。

まとめ

実際にインターフェース型使ってみて、謎のエラーに遭遇するたびにRTLソースを追いかけ這いずり回って得た知見をまとめてみました。
持っててよかったRTLソース。