読者です 読者をやめる 読者になる 読者になる

仕様書をなぞった実装

C++ C#

「ソースコードがドキュメント足りえないのは訓練していないから」 - イトウ アスカ blog

5月という時期だけにこういう話はたくさんある。新人研修にはうってつけの話かもしれない。

仕様書をなぞったようなコードを書くのは、単にそのプログラマがウブだからだと思う。一方で、プログラムが仕様書にならないのもプログラマがウブだからだろう。この状況を脱却するためにもプログラマは二枚舌になるべきだ。つまり、表向きと裏向きで話を変えられるレベルに達しないといけない。

裏の世界にようこそ

基本的に、世の中のまともなプログラマが書いたコード・仕様書には必ず裏がある。仕様書の行間にある裏に気づいてニヤリとできるかどうかがプログラマとしてはかなり重要だ。

そんな、行間を読むような仕様なんて・・・という言い方もあるが、それにはちゃんとした理由がある。

インターフェイスと実装の分離

簡単にするために最も簡単な例で。たとえば次のようなclassがあったとする(言語は、Java/C#/C++の偽物みたいなやつ)。

class Image
{
  public Image(string fileName);
  public int getWidth();
  public int getHeight();
  public void draw(GraphicContext gc, int x, int y, Rect portion);
}

このクラスは画像を読み込んで、表示するクラスであることはおそらく自明だろう。もっとも典型的な利用パターンは、

  Image image = new Image("test.jpg");
  int w = image.getWidth();
  int h = image.getHeight();
  draw(gc, 0, 0, new Rect(0, 0, w, h));

のような感じ。言語がなんなのかは聞かないで。はっきり言って、ここまでの話にあんまり議論の余地はない。ここまでを仕様として提示して、初心者プログラマ君にコードを書かせるとどうなるか。おそらく、コードは次のようになる。

class Image
{
  private int w; // 横幅
  private int h; // 縦幅
  private byte mem[]; // 画像をロードするバッファ

  public Image(string fileName)
  {
    loadHeader(); // ヘッダを読み込むコード
    mem = new byte[w * h * 3]; // メモリを確保
    loadImage(); // メモリに画像をロード
  }

  public int getWidth() {return w;}
  public int getHeight() {return h;}

  public void draw(GraphicContext gc, int x, int y, Rect portion)
  {
    // 本題に関係ないので省略
  }

  ...
}

さて、このコードのどこに問題があるのか。別にどこにも問題はない。仕様の通りに実装するという意味においては、100点である。

さて、問題はここから。この仕様通りにプログラムを書いた後に、実は、このコードを呼び出す側のコードでは、画像が大きすぎると判断した場合にはメモリが逼迫するのを防ぐためにロードをしない方が良いことが判明した。しかしながら、現状のコードでは、コンストラクタ内で画像をロードしているので、表示をしようがしまいが、結局のところ、必ず画像をロードしてしまっている。

さて、どうするか。ここで、新人プログラマ君は、状況を打開するために、このクラスにload()メソッドを追加することを提案した。実装のイメージとしては、

class Image
{
  private int w; // 横幅
  private int h; // 縦幅
  private byte mem[]; // 画像をロードするバッファ

  public Image(string fileName)
  {
    loadHeader(); // ヘッダを読み込むコード
  }

  public void load() // 新しく追加したメソッド
  {
    mem = new byte[w * h * 3]; // メモリを確保
    loadImage(); // メモリに画像をロード
  }

  public int getWidth() {return w;}
  public int getHeight() {return h;}

  public void draw(GraphicContext gc, int x, int y, Rect portion)
  {
    // 本題に関係ないので省略
  }

  ...
}

という感じになる。ただし、仕様は少しだけ複雑になって、画像を表示するdrawメソッドを呼び出す前に必ずloadを呼び出さなければならないということにした。

ここで、実装を隠してしまって、インターフェイスだけにしてみる。

class Image
{
  public Image(string fileName);
  public void load();
  public int getWidth();
  public int getHeight();
  public void draw(GraphicContext gc, int x, int y, Rect portion);
}

このインターフェイスを見たプログラマは仕様書を読まずにこのloadメソッドの意味がわかるだろうか?実装を読むか、あるいは、仕様書を読まないとloadの意味は不明確である。
アプリケーションを作成するプログラマがこのインターフェイス仕様の理解が不十分な場合、プログラムをさんざんクラッシュさせたあげくに、loadというメソッドの存在の意義に気づくだろう。おぉ、このメソッドは今の問題に何か関係あるんだろうかみたいな感じで。

いずれにしても、このコードは既に誰が見ても自明なプログラムではなくなってしまった。このライブラリを初めて使う人にとっては非常に大きなハードルになる。

正しい実装

ここでは、とりあえず、インターフェイスに手を付けるのは望ましくない。元々のインターフェイスは非常に明瞭であり、この程度のインターフェイスならば、仕様書はおそらく必要ない。インターフェイスそのものが自身の意味を十分に説明できている。インターフェイスを修正するのは最後の手段であると考えるべきだ。

ここで活用すべきは、プログラマの三大美徳の一つ、「怠慢」だ。別名、遅延評価、あるいは、「宿題はやらないぜメソッド」とも言う。これを活用すると、次のようなコードになる。

class Image
{
  private int w; // 横幅
  private int h; // 縦幅
  private byte mem[]; // 画像をロードするバッファ
  private string imageFileName; // ファイル名

  private void makeSureHeaderIsLoaded()
  {
    // 横幅・縦幅が確定していなければヘッダを読み込む
    if(w == 0 || h == 0)
      loadHeader();
  }

  private void makeSureImageIsLoaded()
  {
    makeSureHeaderIsLoaded();
    // 画像がロードされていなければロードする
    if(mem == null)
    {
      mem = new byte[w * h * 3]; // メモリを確保
      loadImage(); // メモリに画像をロード
    }
  }

  public Image(string fileName)
  {
    w = 0;
    h = 0;
    mem = 0;
    imageFileName = fileName; // とりあえずファイル名だけ記憶しておく
  }

  public int getWidth()
  {
    // 横幅が必要になった時点で初めてヘッダをロード
    makeSureHeaderIsLoaded();
    return w;
  }

  public int getHeight()
  {
    // 縦幅が必要になった時点で初めてヘッダをロード
    makeSureHeaderIsLoaded();
    return h;
  }

  public void draw(GraphicContext gc, int x, int y, Rect portion)
  {
    makeSureImageIsLoaded();
    // 本題に関係ないので省略
  }
  
  ...
}

プログラムが微妙に長くなったが、見てわかるとおり、コンストラクタは実質的には何もしていない。getWidthやgetHeightが最初に呼び出されたタイミングでヘッダをロードし、drawが初めて呼ばれたタイミングで画像を読み込んでいる。これを遅延評価と言う。必要な処理をぎりぎりのタイミングまでやらないで済ます手法の一般的な名前だ。

確かに余計な条件分岐のせいで、最終的な処理時間はほんの少しだけ遅くなるだろう。しかしながら、このコードは、実際に画像を表示させない限り、最初のプログラムよりも効率的に動作するし、さらに2つ目のコードのような余計なメソッドをインターフェイスに登場させることもない。

何も知らないプログラマがインターフェイスを文字通りに受け取って、コンストラクタで画像がメモリにロードされると思っていたとしても一向にかまわない。彼の理解のレベルで書くプログラムで、この実装の差が問題になることはほとんどない。逆に彼の浅い理解でプログラムを書いていく途中で、「コンストラクタでは、実は何も起きていないこと」を気づいたとすればそれは彼にとってはまたとない成長の機会となる。

まぁ、実のところ、そこそこのレベルのプログラマにしてみれば、仕様書のどこにも何にも書かれていなければ、むしろ、最初からこのレベルのコードを実装することになるので、これで裏とかなんとかいうのは非常におこがましいのではあるが、このような効率的な実装を許すためにも、コンストラクタの説明には、

このコンストラクタは、指定された画像をメモリにロードします。

とは書かない方が良い。強いて書くならば、

このコンストラクタは、指定された画像を準備します。

程度の方が良いだろう。「準備」とは「心の準備」も含むだろうから。この仕様ならば、裏を読めるプログラマならば、画像をロードすべき(される)タイミングはここではない可能性もあるなとニヤリとできる。

まとめ

別にプログラムに限らず、「ウブ」というのは、物事を文字通りに取る行為そのものである。プログラムの顔、インターフェイスは簡単な方が良いし、簡単であれば、仕様書や説明書には最低限度のことを書けば済む。一方で、それは実装がインターフェイス通りであることは意味しない。プログラマは、インターフェイスを変更せずにいかに効率的なコードを実装できるかについて日夜悩み続けるべきだし、プログラムの高速化の余地というものはそうやって生まれてくる。

WindowsのAPIが、Windows 3.1辺りからずっと変わっていないにも関わらず、Windows Vistaまでそのままやってこれたのにもその辺に理由がある。Linuxみたいにカーネルのインターフェイスをちょいちょい変えてしまうのは、最終的な効率には大きく貢献するだろうが、互換性や教育コストを考えると、個人的には望ましいとは思えない。

インターフェイスをもって「しがらみ」と揶揄するのは簡単だし、プログラマ用語で、「レガシー」というのが決して良い意味ではないのも事実ではあるが、とはいえ、プログラマたるもの、一度決めたインターフェイスはなるべく変更せずに実装だけでカバーしようと考えるのは当然のことである。逆に言えば、その苦しみを味わうことこそが、インターフェイス設計の最初の一歩ではないだろうか。