Swanman's Horizon

性欲をもてあましつつなんらかの話をするよ。

GetTypeKindを使って擬似的に細かい型制約のジェネリクスを実現する。

ガード節も使います

前回ガード節は使うなと言ったな、スマンありゃウソだった…というわけではなく、ガード節を使わずに書いた方がいいのは「どんな型が指定されてもいい場合」で、class制約やrecord制約より細かい制約を課したい場合は、逆にガード節を使うと便利です。と言っても今回の主役はガード節ではありません。

細かい型制約

class制約ではTObjectといったクラス型のみ、record制約ではIntegerといった値型のみといった制約を課すことはできますが、列挙型だけ、文字列型だけ、という指定はできません。そこで実行時に型情報を取得し、TTypeKindを調べることでそれらを実現するわけです。もちろんこれはGetTypeKind実装以前のバージョンでも可能ですし、実際すでに色んなプログラムで使用されています。

ポイントは制約外ということをいつ知るか

例えば以下のように書ければコンパイル時に細かい型制約を課すことが可能になるわけですが、残念ながらこの書き方はできません。GetTypeKind自体は組み込み関数なのでコンパイラ指令内でも使えますが、Tが定数と見なされないからです。

{$IF GetTypeKind(T) <> tkUString}
  {$MESSAGE ERROR 'アカン'}
{$ENDIF}

コンパイル時にエラーを出せないのであれば、実行時に出すしかありません。それもなるべく早めに。

どうやって間違いを知るか

ジェネリッククラスで実行時に型をチェックする場合、通常はコンストラクタ内で行います。

type
  TFoo<T> = class
  public
    constructor Create;
  end;

constructor TFoo<T>.Create;
begin
  if not (GetTypeKind(T) in [tkLString, tkWString, tkUString]) then
    raise Exception.Create('');

  ...
end;

見ての通りこのクラスは型引数として文字列だけを取りたいようですが、仮に間違ってIntegerを指定してしまったとしても、その例外が出るのはTFoo.Createというインスタンスの生成コードが走った時になります。走りさえすれば一応間違っているということは分かるには分かりますが、そのコードがすぐに実行されない場所にあった場合、あるいは特定の条件でしか走らない場合、間違っているということを知るのがかなり遅くなってしまいます。

この間違いをなるべく早く知りたい、しかもコードがどこで使われていようとその通知はプログラムが実行された直後に受け取りたい。それを実現する方法がclass constructorと例外の組み合わせです*1

class constructorと例外を組み合わせる

上記のコードにこの組み合わせを導入してみます。

type
  EFooTypeArgumentException = class(Exception)
  private
    class constructor Create;
  end;

  TFoo<T> = class
  public
    constructor Create;
  end;

class constructor EFooTypeArgumentException.Create;
begin
  ShowMessage('TFoo<T>は型引数に文字列以外指定できません');
  Halt;
end;

constructor TFoo<T>.Create;
begin
  if not (GetTypeKind(T) in [tkLString, tkWString, tkUString]) then
    raise EFooTypeArgumentException.Create('');

  ...
end;

このコード、一見するとただのException例外だったのを独自の例外クラスに置き換えただけのように見えます。実際TFoo.Createの方はそこしか変わっていません。ポイントはその独自の例外クラスです。

今回作ったEFooTypeArgumentExceptionクラスはclass constructorを持ちます。class constructorは従来のinitializationを置き換えるような存在で、クラスがプログラム内のどこかで一箇所でも使われている場合、プログラムの起動時にinitializationと同じようなタイミングで実行されます。逆にどこにも使用箇所が無い場合は実行もリンクもされません。

つまり、間違った型指定を行ってしまうと、プログラムにEFooTypeArgumentExceptionがリンクされ、class constructorが実行されることによりプログラムの実行直後にメッセージが表示されてプログラムが終了します。class constructorの実行タイミングは決まっているので、TFoo自体をどこで使っていても即座に間違いが分かるわけです。

しかし一度でも使っていれば実行されてしまうとなると、正しく型指定をした場合でもエラーが出てしまうのではないでしょうか?ここで前回の記事を思い出してください。GetTypeKindを使った箇所は、最適化により実行されないことがコンパイル時に分かっていれば消えてしまいます。それを踏まえて改めてコードを見てみると、今回作った例外クラスはそのGetTypeKindを使ったif文の中だけで使われています。そこでTに正しい型であるstringを指定するとどういう動作になるでしょうか。

// 擬似コード
constructor TFoo<string>.Create;
begin
  if not (GetTypeKind(string) in [tkLString, tkWString, tkUString]) then
    raise EFooTypeArgumentException.Create('');

  ...
end;

まずコンパイラはTをstringに展開します。そしてGetTypeKind(string)は次のように置き換えられます。

// 擬似コード
constructor TFoo<string>.Create;
begin
  if not (tkUString in [tkLString, tkWString, tkUString]) then
    raise EFooTypeArgumentException.Create('');

  ...
end;

この時点でif文は確実にFalseになります。そこでコンパイラは最適化により実行されない部分を取り除きます。

// 擬似コード
constructor TFoo<string>.Create;
begin
  ...
end;

これでEFooTypeArgumentExceptionは消えてしまいました。唯一の使用箇所が消えてしまった以上、この例外クラスをプログラムにリンクする意味はありません。そこでコンパイラはEFooTypeArgumentException自体を無かったことにしてしまうわけです。これでclass constructorが実行されることはありません。

まとめ

Delphiに実装されているジェネリクスは便利な一方、細かいところに手の届かないもどかしさもあります。型制約によるエラーがコンパイル時に得られれば理想ですが、今回の方法ように実行直後に分かるのであれば、コンパイル速度の速いDelphiでは擬似的に細かい型制約を実現できそうです。

*1:理屈的には例外じゃなく普通のクラスでもいいけど、コードの見た目的に例外の方が分かりやすい