趣旨
みなさんは宣言的プログラミングと言う言葉をご存知でしょうか? これはプログラミングをするときのスタイルの一つです。さっそくWikipediaを見てみましょう。多くの場合、後者の具体的なプログラミングパラダイムもしくは、言語を想像してしまうケースが多いと思うので、今回の記事では明確宣言型プログラミングのサポートしていないJavaと、サポートを(おそらく)しているElmで比較をしたいと思います。ElmはWebフロントプログラミングを行うために特化された言語です。それでは、改めて宣言型プログラミングとは一体なんなのでしょうか。Wikipediaでは、処理方法ではなく対象の性質などを宣言することで、プログラミングをする。と記述があります。今回対象として上げるJavaは、手続き型を主に使用してプログラミングをおこなっていきます。具体的な違いは、この記事のモチベーションを見ていった後に実際のコードを例に感覚を掴んでいきましょう。
宣言型プログラミング(英: Declarative programming)は、プログラミングパラダイムの名称だが、主として2種類の意味がある。第1の意味は、処理方法ではなく対象の性質などを宣言することでプログラミングするパラダイムを意味する。第2の意味は、純粋関数型プログラミング、論理プログラミング、制約プログラミングの総称である。後者は(多寡はあれ)前者の性質を持つ。
今回のモチベーションとして手続き型プログラミングを普段やっていらっしゃる方が、宣言型プログラミングに慣れるために。徐々に移行をしていくための脳の切り替えをおこなうための参考になればなと思います。この記事は前編と後編に分かれ、前編は九九の任意の段を出力する簡単なサンプルを通して徐々に宣言型プログラミングのコードにしていくと言う趣旨です。最後に、Elmでのコードを簡単に解説して終わりとなります。後編は具体的にどういう言語の特徴を活かしていくことで、宣言型プログラミングがおこなえるかのテクニックを紹介していきたいと思っています。
九九プログラム
それでは、九九の任意の段を出力するプログラムを書いていきましょう。
今回のプログラムの仕様は任意の段の数を指定し(簡略化のため変数により3の段を指定します。)、半角スペース区切りで1行に出力せよ。これが、今回作るプログラムの内容です。まずはコードを見ずに自分で書いてみると、自分が手続き型寄りなのか命令型寄りなのかを確認できると思います。
まずは、問題をそのまま解釈しJavaの古典的なスタイルで書いた場合のコードになります。任意の段を1行に出力する関数、printNMultiTableメソッドを定義しています。これを言葉で表すと以下のようになると思います。
- 状態(カウンタ)変数iを宣言し、初期値を1とします
- iが9ではないならば、i*nをした結果の後に半角スペースを入れ標準出力に出力します
- iが9ならば、i*nを標準出力に出力します
- 状態変数に1を足し更新し、上記(2, 3)の処理をiが9になるまで繰り返します
- 繰り返しが終わったならば、改行を標準出力に出力しメソッドを抜けます
このコードは次の点において手続き型プログラミングと言えます。
- 変数iを書き換えていくことで繰り返しを処理している。つまりPCのメモリの書き換え処理を意識している
- 結果を標準出力に出力することを意識している
つまり、コンピュータが処理する内容と九九を計算する内容が混在していることになります。
public class Test {
public void printNMultiTable(int n) {
for (int i = 1; i <= 9; i++) {
if (i != 9) {
System.out.print(i * n + " ");
} else {
System.out.print(i * n);
}
}
System.out.println();
}
public Test() {
printNMultiTable(3);
// => 3 6 9 12 15 18 21 24 27
}
public static void main(String args[]) {
new Test();
}
}
ここで次のステップに行く前に、上記の手続き型スタイルのメリットとデメリットについて考えたいと思います。手続き型は、計算した結果を最終的なゴールである標準出力をおこなうこと、が明確に表れているコードになります。これは上手く行えばパフォーマンスに優れた結果となります。一方、仕様を知らない人がこのコードを見たときに、このスペース出力はなんだろう? 最後の改行はなんだろう?と推論しなければなりません。推論を防ぎ仕様を残すには、テストコードを書くことがより良い手法としてあげられます。しかし、標準出力されてしまった結果のテストコードは容易ではありません。デバッガを使って目で確認をしたり、ファイルに出力を行いファイルのdiffを取るなど、本来の目的から逸れた努力が必要になってしまいコストに見合いません。宣言型プログラミングは、これらの問題を解決するのでしょうか? それでは次のステップに移りたいと思います。
次のステップでは最終的なゴールではなく、計算の結果を意識していきます。具体的には、3の段を表すListを返すメソッドnMultiTableと、Listの中身をスペース区切りで出力するprintListメソッドの2つに分けます。こうすることで、nMultiTable(3)と言うメソッド呼び出しは、式であり評価された結果が3の段を表すListと等しくなると言うテストコードを書くことができます。このステップの問題点としては、メソッドの中自体に手続き型の箇所が残っており、コードの推論がまだ難しいです。
import java.util.List;
import java.util.ArrayList;
public class Test {
public List<Integer> nMultiTable(int n) {
final List<Integer> table = new ArrayList<>();
for (int i = 1; i <= 9; i++) {
table.add(i * n);
}
return table;
}
public void printList(List<Integer> list) {
for (int i = 0; i < list.size(); i++) {
if (i != list.size() - 1) {
System.out.print(list.get(i) + " ");
} else {
System.out.print(list.get(i));
}
}
System.out.println();
}
public Test() {
List<Integer> threeTable = nMultiTable(3);
// == Arrays.asList(3, 6, 9, 12, 15, 18, 21, 24, 27)
printList(threeTable);
}
public static void main(String args[]) {
new Test();
}
}
nMultiTableのコードの改善をしていきましょう。forループを回すための状態変数iが九九のベースの数字列を生成していました。これを宣言的にする一番愚直な解決策は、1-9までの数字を直接列挙してしまうことです。
import java.util.List;
import java.util.ArrayList;
import java.util.Arrays;
public class Test {
public List<Integer> nMultiTable(int n) {
final List<Integer> table = new ArrayList<>();
final List<Integer> base = Arrays.asList(1, 2, 3, 4, 5, 5, 6 ,7, 8, 9);
for (int x : base) {
table.add(x * n);
}
return table;
}
public void printList(List<Integer> list) {
for (int i = 0; i < list.size(); i++) {
if (i != list.size() - 1) {
System.out.print(list.get(i) + " ");
} else {
System.out.print(list.get(i));
}
}
System.out.println();
}
public Test() {
List<Integer> threeTable = nMultiTable(3);
printList(threeTable);
}
public static void main(String args[]) {
new Test();
}
}
しかし、手で列挙するのはあまりにも強引な解決策だったため、IntStream#rangeClosedと言う範囲のstreamを作り出すメソッドを使い、collectでListに変換します。これは最初のforを回すことと何の違いがあるのでしょうか? 今回はとても簡単な内容だったから大きな差はありませんが、このbaseが状態の書き換わりではなく具体的なListになったことで、これもテストの対象として考えることができます。また、1-9のListが生成されることがメソッドにより保証されているため、forがiを書き換えるものから拡張forに代わり、インクリメントミスなどの間違いを防ぐことが出来ます。
import java.util.List;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.stream.IntStream;
import java.util.stream.Collectors;
public class Test {
public List<Integer> nMultiTable(int n) {
final List<Integer> table = new ArrayList<>();
final List<Integer> base = IntStream.rangeClosed(1, 9).boxed().collect(Collectors.toList());
for (int x : base) {
table.add(x * n);
}
return table;
}
public void printList(List<Integer> list) {
for (int i = 0; i < list.size(); i++) {
if (i != list.size() - 1) {
System.out.print(list.get(i) + " ");
} else {
System.out.print(list.get(i));
}
}
System.out.println();
}
public Test() {
List<Integer> threeTable = nMultiTable(3);
printList(threeTable);
}
public static void main(String args[]) {
new Test();
}
}
ベースとなる数列と九九の段の数を掛け合わせていましたが、数列を残しておく必要は必ずしもありません。IntStream#mapToObjメソッドを呼び出して、ベース数列のすべての要素を掛け算した結果のStreamに変換します。Streamの紹介記事ではないため、具体的な意味はリファレンス等を参照してください。
import java.util.List;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.stream.IntStream;
import java.util.stream.Collectors;
public class Test {
public List<Integer> nMultiTable(int n) {
return IntStream.rangeClosed(1, 9).mapToObj(x -> x * n).collect(Collectors.toList());
}
public void printList(List<Integer> list) {
for (int i = 0; i < list.size(); i++) {
if (i != list.size() - 1) {
System.out.print(list.get(i) + " ");
} else {
System.out.print(list.get(i));
}
}
System.out.println();
}
public Test() {
List<Integer> threeTable = nMultiTable(3);
printList(threeTable);
}
public static void main(String args[]) {
new Test();
}
}
一行で済んでしまったので、nMultiTableメソッドを一旦消してしまいましょう。
import java.util.List;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.stream.IntStream;
import java.util.stream.Collectors;
public class Test {
public void printList(List<Integer> list) {
for (int i = 0; i < list.size(); i++) {
if (i != list.size() - 1) {
System.out.print(list.get(i) + " ");
} else {
System.out.print(list.get(i));
}
}
System.out.println();
}
public Test() {
int n = 3;
List<Integer> threeTable = IntStream.rangeClosed(1, 9).mapToObj(x -> x * n).collect(Collectors.toList());
printList(threeTable);
}
public static void main(String args[]) {
new Test();
}
}
次にprintListに注目します。Listの中身を半角スペース区切りで標準出力する。この処理を宣言的に考えると、数字の列を文字列の列に変換し、半角区切りの文字列に直すことであると言えます。String#joinはリストを指定した文字列で区切った文字列にするメソッドでおあつらえ向きです。printListではなくprintableListとします。先ほどのthreeTableもmapToObjを呼び出す際に、文字列に変換するようにします。
import java.util.List;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.stream.IntStream;
import java.util.stream.Collectors;
public class Test {
public String printableList(List<String> list) {
return String.join(" ", list);
}
public Test() {
int n = 3;
List<String> threeTable = IntStream.rangeClosed(1, 9).mapToObj(x -> String.valueOf(x * n)).collect(Collectors.toList());
System.out.println(printableList(threeTable));
}
public static void main(String args[]) {
new Test();
}
}
よく考えると、collectメソッドで終端処理をするときに文字列に変換できそうです。Collectors#joiningメソッドを呼び出してあげましょう。何とわずか一行で手続き型コードに変換することができてしまいました!
import java.util.List;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.stream.IntStream;
import java.util.stream.Collectors;
public class Test {
public Test() {
int n = 3;
String threeTable = IntStream.rangeClosed(1, 9).mapToObj(x -> String.valueOf(x * n)).collect(Collectors.joining(" "));
System.out.println(threeTable);
}
public static void main(String args[]) {
new Test();
}
}
しかし、待ってください。過度なコードの省略はテストコードを減らしてしまい、コード全体の安全性や逆に可読性も下げてしまいかねない結果になってしまいます。実用的なプログラミングでは、このバランスに注意してリファクタリングをおこないましょう。nMultiTableメソッドは戻して、任意の段の数列に対してテストコードが書けるように戻しておきます。一方、スペース区切りの文字列は、最終的な出力のために加工したものに過ぎないので、テストは不要と判断しました。この判断は唯一の正解ではありません。あくまで、実装の要件を考慮しながら設計をおこないましょう。
import java.util.List;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.stream.IntStream;
import java.util.stream.Collectors;
public class Test {
public List<Integer> nMultiTable(int n) {
return IntStream.rangeClosed(1, 9).mapToObj(x -> x * n).collect(Collectors.toList());
}
public Test() {
final int n = 3;
final List<Integer> threeTable = nMultiTable(3);
// == Arrays.asList(3, 6, 9, 12, 15, 18, 21, 24, 27)
String threeTableText = threeTable.stream().map(String::valueOf).collect(Collectors.joining(" "));
// == "3 6 9 12 15 18 21 24 27"
System.out.println(threeTableText);
}
public static void main(String args[]) {
new Test();
}
}
先ほどのステップで注意を促しましたが、宣言的プログラミング == StreamAPI を使ったプログラミングではありません。また、宣言的プログラミングにすることが優れた手法ではありません。時には手続き型プログラミングをおこなったほうが良いケースも多く存在します。特にJavaは元々、手続き型ベースのOOPをサポートする言語なので手続きによる優れたやり方が提供されているケースが多々あります。そのため、手段が目的にならないようにバランスを注意をしながら宣言型プログラミングへの移行をおこなってみましょう。
Elmを使った宣言型プログラミング
それでは宣言型プログラミングを主にサポートするElmの例を見てみましょう。繰り返し処理をおこないたい場合の大きな武器は再帰呼び出しです。letでは最終的に値を返すために必要な式を定義します。inでは最終的な値を返すための式を記述します。この式が性質そのものを表し、値が表した性質の結果となります。後編で様々なElmの機能を紹介していきますが、簡単に言うと全てが式でなりたっています。式は文と違い必ず値を返します。そのため結果を返すかもしれないと言う曖昧な式は書けずに、全てを厳密に書く必要があります。以下の例では、IFが式になっています(厳密なためelse節の省略はできません)。ifの内容は以下のようになっています。
xが10のとき table(Listを返します)。これは最終結果を表す
それ以外のとき、リストの先頭を n * x (nは段の数、xはベース数列の要素)とし、後ろの要素をx+1した再帰呼び出しする
つまり、3の段の場合
(3 * 1) :: (3 * 2) :: (3 * 3) ... (3 * 9) :: [] == [3, 6, 9, ..., 27]
となります。
inでは、xは1からスタートし、最初は空のリストなので明示的に渡した式となります。threeTableTextでは3の段のリストを文字列のリストに変換し、スペース区切りの文字列に変換しています。これはJavaの例と一緒です。
module Test exposing (..)
import Html exposing (text)
nMultiTable : Int -> List Int
nMultiTable n =
let
nMulti x table =
if x == 10 then
table
else
(n * x) :: nMulti (x + 1) table
in
nMulti 1 []
main =
let
n =
3
threeTable =
nMultiTable n
threeTableText =
threeTable |> List.map toString |> String.join " "
in
text threeTableText
もちろんStreamAPIと同じやり方はでき、最終的なコードの意味はJavaの最終コードとほぼ同じなります。しかし、Javaの場合と異なる点は、Streamのように特殊なデータ構造を持ち出さなくても、デフォルトのリストが多くの宣言型プログラミングをおこなうための便利なメソッドを内包しています。そのため、とてもシンプルなコードに仕上がります。
module Test exposing (..)
import Html exposing (text)
nMultiTable : Int -> List Int
nMultiTable n =
List.range 1 9 |> List.map (\x -> x * n)
main =
let
n =
3
threeTable =
nMultiTable n
threeTableText =
threeTable |> List.map toString |> String.join " "
in
text threeTableText