副作用を考える

2020年1月17日

これは「Haskell が教えてくれたこと」の続きに当たる記事ですが、どちらを先に読んでいただいても構いません。

Haskell には「副作用」という概念が登場します。Haskell でプログラムを書いていると、副作用というものを意識せざるを得なくなり、その影響はやがて C++ のスタイルにも及んでいきます。もちろんいい意味でです。

今では私は C++ であっても常に副作用を意識したプログラムを書くようになり、嬉しいことにこれらのプログラムはとてもうまく動いてくれています。

今回の主張は、「副作用を押しのけるようにプログラムを書こう」というものです。今回も、Haskell のコードは出てきません。

関数における副作用

副作用とは、関数に対して使う用語です。「副作用のない関数」、「副作用のある関数」といった使い方をします。

副作用のない関数を一言で説明すると、「引数を取って戻り値を返すだけの」関数ということになります。典型例は数学関数です。たとえば sqrt 関数は、引数の平方根を返す以外の仕事をしません。

副作用とみなされる行為

そうでなければ、それは副作用のある関数です。たとえば関数が次のような行為を一つでも含んでいれば、それは関数の副作用とみなされます。

  • グローバル変数を読む、あるいは書く
  • ペリフェラル[note]ペリフェラルとは、組み込み業界ではおなじみの「周辺機能」のことです。レジスタと呼ばれることがありますが、そう呼んでしまうと汎用レジスタと混同する恐れがあるため、ここではペリフェラルに統一します。汎用レジスタの読み書きは、もちろん副作用ではありません。[/note]を読む、あるいは書く
  • 標準入力または標準出力を使う
  • 自身がスタティック変数を持つ
  • マイコン固有の命令を使う
  • 副作用のある関数を呼ぶ

「読む」行為でも副作用となり得る点に注意が必要です。これは一般に「副作用」という言葉が喚起するイメージとは若干異なりますので、強調しておきます。

副作用とみなされない行為

次の行為は、副作用ではありません。

  • 定数に依存する
  • 副作用のない関数を呼ぶ
  • 引数としてのポインタを介して値を読む、あるいは書く
  • 引数として受け取った関数を呼ぶ

ポインタが引数として受け取ったものなら、その先がグローバル変数だろうとペリフェラルだろうと、それに対するアクセスは副作用とはみなされません。

そして、最後の項目が特に重要です。引数として受け取った関数が副作用を持つか持たないかは、ここでは問われません。この事実は今回の話題に深く関わってきます。なぜなら、副作用のある関数を引数として受け取ることが、いわば「副作用逃れ」のテクニックとして用いられるからです。

コード例を示します。次の div 関数は、副作用のある関数です。puts 関数が、標準出力を使うからです。

int div(int a, int b)
{
  if (b == 0) { puts("Divided by zero!"); }
  return a / b;
}

ところが次のようにすると、div 関数には副作用がないことになります。たとえ実際に puts 関数を呼んでいてもです。

int div(int a, int b, int (*func)(const char *))
{
  if (b == 0) { (*func)("Divided by zero!"); }
  return a / b;
}

屁理屈のようにも思えますが、実際にこの理屈が後で効いてきます。一旦はこのように覚えておいてください。

なぜ副作用がいけないか

前置きが長くなりました。ここからが本題です。関数が副作用を持つとなぜいけないのか、それは一言で言い切れます。

単体テストができないからです。

これは組み込みプログラマーこそが肝に銘じておかなければならない要点です。このことがどのような形で組み込み屋の首を絞めることになるのか、エピソードを一つ示したいと思います。

T君の悲劇

T君は温度センサから温度を読み取るプログラムを担当しています。ただし、温度センサには既知のバグがあり、ごく稀に異常値を示すことがわかっていました。そこで、同じセンサを3度読み、多数決方式で値を採用することにしました。

extern int readCelsiusFromSensor();  // センサから温度を読む

int getCelsiusFiltered()
{
  int c1 = readCelsiusFromSensor();
  int c2 = readCelsiusFromSensor();
  int c3 = readCelsiusFromSensor();
  if (c1 - c2 <= 1) { return c1; }
  if (c2 - c3 <= 1) { return c2; }
  if (c3 - c1 <= 1) { return c3; }
  return (c1 + c2 + c3) / 3;
}

T君はこのように実装し、粘り強く実機テストをすることで実際に異常値が除外されるところも確認できました。にもかかわらず、結局このプログラムは出荷後にバグを出してしまいました。

単体テストの威力

バグの原因は、二つの値の差を評価する式の左辺で絶対値を取っていなかったことでした。ここでつい、T君のコーディング能力に責めを負わせたくなりますが、それよりも問題にしたいのはアプローチです。T君は、実機テストで粘る必要などなかったのです。

先ほどの「副作用逃れのテクニック」を思い出してください。これをT君の関数に適用すると、次のようになります。

int getCelsiusFiltered(int (*readCelsius)())
{
  int c1 = (*readCelsius)();
  int c2 = (*readCelsius)();
  int c3 = (*readCelsius)();
  if (c1 - c2 <= 1) { return c1; }
  if (c2 - c3 <= 1) { return c2; }
  if (c3 - c1 <= 1) { return c3; }
  return (c1 + c2 + c3) / 3;
}

この関数がT君の関数と決定的に違うのは、単体テストができるという点です。この関数の引数として温度センサを読む関数を与えれば、もともとの動きと同じにすることもできます。しかしテストのときは、いろいろな値を与えてみればいいのです。たとえば次のように。

extern int getCelsiusFiltered(int (*readCelsius)());

static int g_n = 0;
static int g_c[3];
static int readCelsius() { return g_c[g_n++]; }

int main()
{
  static const struct {
    int c[3];     // 入力値
    int expected; // 合格値
  } cases[] = {
    // テストケースをここに並べる
    { { 10, 10, 10 }, 10 },
    { { 11, 11, 99 }, 11 },
    { { 12, 99, 12 }, 12 },
    { { 99, 13, 13 }, 13 },
  };

  for (int i = 0; i < sizeof(cases) / sizeof(*cases); ++i)
  {
    g_n = 0;
    g_c[0] = cases[i].c[0];
    g_c[1] = cases[i].c[1];
    g_c[2] = cases[i].c[2];
    if (getCelsiusFiltered(&readCelsius) != cases[i].expected)
    {
      printf("NG\n");
      return 1;
    }
  }
  printf("OK\n");
  return 0;
}

このコードをビルドして実行するのに、実機を持ち出す必要すらありません。紙面の都合で短く書いていますが、この方法なら気になるケースは片っ端から試すことができます。実機で粘るより、何百倍も強力ですよね。

まとめ

副作用を押しのけるようにプログラムを書きましょう。特にバグが気になる複雑なロジックがあれば、副作用を分離したうえで、単体テストにかけましょう。

ただし、この世に副作用のないプログラムはありません。副作用を「押しのけるように」と言ったのはそのためです。押しのけた副作用はいずれどこかに偏りますが、それこそが理想形です。副作用をひとところに押し付けて、単体テスト可能な領域を増やすように、プログラミングしてみてください。あなたのプログラムの品質は劇的に変わります。