ループとStreamの例から、プログラミングの核心を理解する
ここで、逐次実行についての理解度をチェックするために、きしだ氏はループ(繰り返し処理)を例に解説を始めた。「はじめは簡単だと思っていても、どこかの段階で『難しい』と感じるはず」ときしだ氏。どの部分で引っ掛かったかによって、プログラミングの基礎理解度が測れるわけだ。
まずは、ループの中で最も簡単な、同じことを無限に繰り返す処理だ。サンプルコード[1]では、"Hello"という文字列を無限に出力する例が示されている。(System.outの省略は9月にリリースされたJava 23で可能になっている。)
次のサンプルコード[2]は、回数が決まっているループだ。このようなCスタイルの構文において、同じことを何回か繰り返す構文はやや複雑なため、初心者には理解が難しい場合がある。
そこで、サンプルコード[2]をより分かりやすく記述したのが、サンプルコード[3]である。JavaのIntStream.rangeメソッドを使用することで、処理が直感的に理解しやすくなる。
次のサンプルコード[4]は、処理にループの変数を使うという例だ。ループの中にあるカウンタを使うため少し難しくなり、ここで躓いてしまう人もいる。
「ループ変数は状態なので、 [println] が実行されるたびに {i} の値が変わる。この部分が逐次実行されていることが分からないと、理解が難しい」(きしだ氏)。
またサンプルコード[5]は、回数の指定ではなく、リストをループで処理する例も示された。リストに含まれる要素全てを大文字に変換し、出力するものだ。
ここまでがループの基本的な処理であり、「つまずく人はそれほど多くない」。ところが、次に示すループによる値の集約になると、「入門者でなくとも、書けない人がたまに出てくる」ときしだ氏は話す。
たとえばサンプルコード[6A]は、「リストに入っている数値を合計する」という問題だ。リストに入った数値が1つずつresultに加算されていき、ループのたびにresultの値が変わっていくという処理がなされている。
この処理が理解できない場合、傍目からは「変数が分かっていない」ように見えてしまう。しかしながら実際には、ループが毎回逐次処理されることがイメージできておらず、resultの値が変わることの意味が分かっていない状態なので、いくら変数について説明されても理解は進まない。
なおかつ、コードを丸暗記して済ませてしまうと、「リストの要素が条件を満たすかチェックする」「条件を満たす値のリストを作る」といった応用問題には対応できない。これら3つは「ループによって値を集約する」という、共通した構造を持つにもかかわらずだ。
なお、ループによる値の集約は、Streamに置き換えて記述することも可能だ(サンプルコード[6B])。値を加算する場合には、sum()と書くだけで簡単に合計が出る。
サンプルコード[7A]は、サンプルコード[6A]の応用として、リストの要素が全て奇数かどうかを判定する処理を示したものだ。resultの値とループのnが奇数かどうかを判定し、その結果をresultに代入している。
こちらの例も、Streamに置き換えて記述ができる(サンプルコード[7B])。条件を全て満たすかどうかの判定には、allMatch()というメソッドを使用しているのがポイントだ。
またサンプルコード[8A]では、IntStream.builderを使用して、リストの中から偶数の数値だけを動的に構築する例も示された。Streamに置き換える場合には、toArrayメソッドを使用してリスト生成を行っている。
サンプルコード[6A]と[6B]、[7A]と[7B]、[8A]と[8B]をそれぞれ比較してみると、ループを使用したコードよりもStreamを使用したコードの方が可読性が高く、処理を追いやすいことが分かる。
きしだ氏はStreamの利点について、「宣言的にコードを書けるところだ」と説明する。人間はどうしても処理を追うことが苦手なので、ループを追いかけるよりも、宣言的なコードを読むほうが理解しやすい。
ただしStreamは、前後のデータに依存する処理には向かない、ときしだ氏は補足する。例えば移動平均は、前後の複数のデータの平均を取り、その範囲をどんどんずらしていくというものであり、Streamの使用には不向きだ。(なお、2024年3月にリリースされたJava 22では、Gathererによって前の値を踏まえたStreamの処理が行えるようになった。)
講義はさらにレベルアップする。きしだ氏は「ここまでの説明では、データをそのまま使ってきたため比較的分かりやすかったかもしれない。しかし、隠れた状態を使うとなると、理解はさらに難しくなる」と前置きし、 [abc(def)ghi] と [abc((def)ghi] という2つの文字列から、対になるカッコを判別する処理を例示した。前者はカッコが1対になっているのに対し、後者は閉じカッコが1つ多くなっている。
このようなカッコの対応を判定する場合、カウンタを使う方法が有効である。具体的には、開きカッコが来たらプラス1、閉じカッコが来たらマイナス1するというものだ。最終的にカウンタが0以下になれば、閉じカッコが多いことになり、1以上で終われば、閉じカッコが少ないということになる。
「このような『隠れた状態』さえ見つけられれば、コードを書くのは簡単だ。ただしこの場合、コードだけを見てもカウンタの意味は分からないので、コメントを残す必要がある」ときしだ氏は補足する。
さらにきしだ氏は、複雑な状態遷移について触れる。たとえばある数が10進数の整数であるか(「00」から始まらないかどうか)をチェックするには、以下の3つのという処理を行わなければならない。
- 最初の文字が「0」の場合、「ZERO」状態に遷移
- 最初の文字が「1〜9」の場合、「INT」状態に遷移し、その後の文字が「0〜9」であれば「INT」状態に留まり続ける。
- すべての文字列について状態遷移が完了すると、終了状態(EOL)になる
きしだ氏によれば「これを表現するコードを書くこと自体は簡単だが、そもそもの状態遷移を理解することが難しいため、はじめからコードを書くのではなく、状態遷移図で整理することをおすすめしたい」と話した。
ここできしだ氏は、「私たちの日常は、状態遷移に溢れている」と話す。たとえば業務処理における購入、入金、配送という流れは、まさに状態遷移の一例だ。これらを個別に処理してしまうと、「何らかの例外があった際に、場当たり的なフラグを次々と生成してしまい、最終的には破綻する」。一連の流れを状態遷移として捉え、管理することで破綻のないコードが生み出されるというわけだ。