2008年3月26日水曜日

配列を「集計」するときの手順のわかりにくさ

1. 配列の要素を集計をするときに感じた違和感

配列の要素を集計をすることを考える。

例えば、1 ~ 5 までの整数に対して、

1 + 2 + 3 + 4 + 5

の答えを求める場合、次のような手順を踏む。

Ruby で書くなら、

ary = [1, 2, 3, 4, 5]

total = 0
for elem in ary
  total += elem
end

puts total

もちろん、慣れているので、この計算の方法に抵抗は感じない。

しかし、この手順を初めて見たとき、

「わかりにくい」

と感じた。極めてシンプルなコードなんだけれど、どこか頭が混乱するような、そんな感覚に陥ったことを覚えている。

では、その原因はどこにあったのだろうか?

 

2. わかりにくい理由は、「要素の走査」「計算」「変数の再代入」を行なっているから

頭が混乱する印象を受けたのは、以下の箇所。

total += elem

コードの意味を、わかりやすくするために、書きなおす。

total = total + elem

全体のコードで行なっていることは、

  1. for によって要素を走査し、
  2. その途中で計算を行う。

気になるのは、total の使い方。 配列を走査している最中に、

  1. 集計するための total から値を取り出し、
  2. それを計算の後、再び total へ設定する。

つまり、以下の 3 つの要素が絡み合っているため、何をやっているのか想像しにくい。

  • 要素の走査
  • 計算
  • 変数の再代入

 

3. 最初は集計の方法をイディオムとして覚えた

プログラミングを始めた当初は、厳密にどのように動いているかというよりも、

このようなイディオムによって集計を行うんだ

と、感覚的な理解をしていた。

今では、配列の要素を「集計」しようと考えたとき、自然と上記のようなコードを書く。しかし、これがどのように動くかを、頭の中で全て思い描けるかと言えば、実はあやしい。デバッガを動かし、それぞれの変数の動きを目で追い、「なるほどなぁ」と思う。それでもコードを見ると、だまされたような感覚に陥る。

ところで、自分の頭の容量は小さい。同時に色々なことを覚えておくことも、把握しておくこともできない。将棋の棋士は、同時に何手先も読むと言うが、あれはいったいどういう頭の仕組みをしているのだろうか。だから、自分の「集計」に対する理解は、まるで刺激に対する反応するようなものだと感じる。これが要求されたら、この方法をとる。まるで、条件付けされているパブロフの犬のようだ。

 

4. 計算の様子をイメージすると、わかりにくい理由がより明確になる

集計をするコードに戻る。

変数 total が、全体を理解する上で、やっかいな存在。 最初に変数が宣言されているのが、for ループの外にある。つまり、要素を走査することとは、別の文脈に存在する。それが要素を走査している文脈に絡んでくるからわかりにくい。

「いつ、どこで、何をしているのか?」

が把握しにくい。

特に、total に elem の値を加算した後、自分自身に再代入しているので、

「誰がどうなったの?」

って感じる。 (@_@;)

イメージしにくいので、絵を描いてみる。そうするば、見通しが立てやすくなる。

080326-001

  1. 配列の要素を elem に割当てる。
  2. total と elem を加算する。
  3. 上記の結果を total に割当てる。

1 ~ 3 を要素ごとに繰り返す。

絵を描いてみると、「加算」を行っている文脈と、 total が存在する文脈が異なっていることがはっきりする。しかし、コード上では、

total = total + elem

のように、一文で簡潔に表現しされている。

この点が、自分のように容量の小さい頭には、イメージするのが難しい所。複数の文脈が一箇所に集約されていることが混乱の元になっている。

 

5. 計算の手順を分離し、各々役割をクラスに与える

では、これを理解しやすくするには、どうすればいいのだろう?

「要素の走査」「計算」「変数の再代入」を、次のように二つに分けて考えることにした。

  • 「要素の走査」
  • 「計算」「変数の再代入」

下図のようなイメージした。

080326-002

集計をするための Total クラスを作り、集計をするための役割を与えた。つまり、「計算」「変数の再代入」の操作を Total クラスにカプセル化。 (まぁ、普通こんなことはしないけれど ^^;)

# 集計の値を保持するクラス
class Total   attr_reader :value  # 集計の値   # 初期化   def initialize    @value = 0   end   # 集計の値に val を加算   def add(val)    @value += val   end
end

add メソッドの中身を見ると、「与えられた値を、これまでの値に加算する」という、極めてシンプルな役割を持っていることがわかる。

このクラス使うには、以下のようにする。

ary = [1, 2, 3, 4, 5, 6]

total = Total.new
for elem in ary
  total.add(elem)
end

puts total.value

for ループの中身を見ると、要素を走査し、Total に「要素の値を追加してね☆」とお願いしているだけになる。

繰り返すが、普通こんなコードは書かない。ただし、自分のようにワーキングメモリの小さい脳みそにとって、このような表現の方が、動作の理解はしやすいと感じる。

 

6. Enumerable の inject を使う場合

ところで、Ruby を使っているなら、イテレータを使って、次のように書くことができる。

total = 0
[1, 2, 3, 4, 5, 6].each do |elem|
  total += elem
end
puts total

Enumerable の inject メソッドを使えば、より簡潔に、

puts [1, 2, 3, 4, 5, 6].inject{|result, item| result + item}
ただし、シンプルだけど、動作をイメージしにくい。

Enumerable - Rubyリファレンスマニュアル によると、

inject([init]) {|result, item| ... }

最初に初期値 initself の最初の要素を引数にブロックを実行します。2 回目以降のループでは、前のブロックの実行結果と self の次の要素を引数に順次ブロックを実行します。そうして最後の要素まで繰り返し、最後のブロックの実行結果を返します。 ...

初期値 init を省略した場合は、最初に先頭の要素と 2 番目の要素をブロックに渡します。この場合、要素が 1 つしかなければブロックを実行せずに最初の要素を返します。要素が空なら nil を返します。

inject の意味は、Yahoo!辞書 - inject によると、

1 …を(…に)注入する((into ...));…に(…を)注入[導入]する, 入れる((with ...))

inject a tank with water [=inject water into a tank] タンクに水を注ぎ入れる

ブロックで実行した結果を、再注入するという意味合いなのだろうか?

あぁ~、それにしても動作を想像しにくい。 パタッ(o_ _)o~†

とりあえず、絵に描いておこう。

080326-001

  1. 先頭から 2 つ要素を取り出し、ブロック変数へ入れる。
  2. ブロックで実行した結果と、次に要素を取り出し、ブロック変数へ入れる。

これを末尾まで繰り返す。

 

7. 「再帰」で集計するには

for ループを使った計算は、再帰的な定義で置き換えることができる。

ary = [1, 2, 3, 4, 5, 6]

def total(ary)
  return ary[0] if ary.size == 1
  return ary[0] + total(ary[1..ary.size-1])
end

puts total(ary)

これは、次のように集計の手順を考えていると言える。

合計 = 要素の先頭 + 2 番目以降の要素の合計

要素が一つしかない場合は、当然、次のようになる。

合計 = 要素の先頭

イメージとしては、

080326-002

「先頭と、それ以降、という構造」が、リストの至るところで見られると見なし、順次関数を適用していく方法。

この方法では、「要素の走査」という部分が、「再帰的な関数の適用」にすりかわり、「変数の再代入」が消失し、「計算」のみが残っている。しかし、これが直観的にわかりやすいかどうかと問われたら、微妙。 ^^;

慣れの問題なのかな?