はじめに
当初は、オブジェクト指向プログラミングと、その特徴についてまとめていたのですが、どうもうまく出来なくて、断念してしまいました。
それで、その代わりに簡単な課題をつくって、それを具体的にプログラミングして、比較していくのが良いかな、と思って、まずは簡単に足し算プログラムから始めようと思ったことがきっかけです。
言語としては、手続き指向型言語としてPascal (標準Pascal)、オブジェクト指向型言語として Java、関数型言語として Haskell を選びました。
この言語チョイスは完全に趣味です。うまく言えないのですが、シングルパラダイムで、静的な型を持つ言語で、シンタックスが長くなりがちで、ちょっとこう「いかつい」感じのする言語なところが好きです。
足し算といっても、ただ一行で計算するのではなく、ユーザーからの入力を受け付ける感じで、次のように機能を定義します。
1. 最初に「数を入力して下さい。空行で終了です。」と表示する
2. 「>」を表示して入力待ちになる。数字を入れて改行すると、それが記録される
3. 再度「>」を表示して入力待ちになる。何も文字を入れずに、改行するまで繰り返す。
4. 空改行されたら、それまでに入力されたすべての数の合計を足し算した結果を表示する。「合計はxxxです」と表示する。
5. なお、数字以外の文字を含んだ入力は無視する
本当はもうちょっと複雑なサンプルの方が、各言語の個性が出てくるとは思うのですが、まずは第一歩ということで。
実際の動作イメージはこんな感じです。
数を入力して下さい。空行で終了です。
>10
>20
>4
>
合計は34です。
この記事は、何か一つプログラミング言語を学んだことがあり、条件分岐やループ処理などが理解出来るくらいの初学者が読むことを想定しています。
Pascalでの実装
ここでは標準Pascalで考えてみます。標準Pascalは機能も少なく、また、分割コンパイルも出来ないのですが、プログラミングの学習、特にアルゴリズムの学習の基礎を学ぶのには、現在でも適しているように思います。
まず、メインルーチンを次のように実装することを考えます。
program CalcSumApp;
type
ItemPtr = ^Item;
Item =
record
next: ItemPtr;
text: ShortString;
end;
// 各種関数の定義
var
lines : ItemPtr;
sum : Integer;
begin
writeln('数を入力して下さい。空行で終了です。');
lines := readLines();
sum := sumOfLines(lines);
writeln('合計は' + toStr(sum) + 'です。');
disposeAll(lines);
end.
最初に、ItemPtrというポインタを定義しています。実は、標準Pascal(ISO 7185 Pascal)では、可変長の配列を利用することが出来ないので、ここではこのようにItemという数値を一つだけ格納出来るNodeを定義し、そのポインタという形で入力された数値を格納するようにしました。
メインルーチン(begin
以下の部分)は、次のようになります。
1行目でメッセージを表示し、2行目で数値を(複数行)受け付けます。
そして、3行目で、入力した数値の合計を計算し、4行目で結果を表示しています。toStr
は、整数を文字列に変換する関数ですが、これも標準pascalにはないので、プログラム内で実装しています。
5行目は、メモリの解放です。ポインタは終わった段階で自分で解放しなくてはいけません。
さて、次は入力部分のreadLines
の実装を考えてみます。
function readLines(): ItemPtr;
var
result: ItemPtr;
prevItemPtr: ItemPtr;
currItemPtr: ItemPtr;
line: ShortString;
begin
result := nil;
prevItemPtr := nil;
while true do
begin
write('>');
readln(line);
if line <> '' then
begin
if prevItemPtr = nil then
begin
new(prevItemPtr);
prevItemPtr^.text := line;
prevItemPtr^.next := nil;
result := prevItemPtr;
end
else
begin
new(currItemPtr);
currItemPtr^.text := line;
currItemPtr^.next := nil;
prevItemPtr^.next := currItemPtr;
prevItemPtr := currItemPtr;
end;
end
else
break;
end;
readLines := result;
end;
全体をwhileループでくくり、条件(空改行入力)されるまで繰り返す形式にします。
readln(line)
が、文字入力を受け取っている部分です。
初回だけは特別で、入力した値をprevItemPtr
に保持します。new(prevItemPtr)
は、ヒープメモリに領域を確保する命令で、入力された数値をそこに格納し、結果(result
)に保存します。
2回目以後は、入力した値をcurrItemPtr
に保持します。そして、入力された数値を格納し、それ一つ前のprevItemPtr
から参照させます。最後に、currItemPtr
を新しいprevItemPtr
として更新します。
次は、足し算を計算しているsumOfLines
関数です。
function sumOfLines(lines: ItemPtr): Integer;
var
a : Integer;
v : Integer;
err: Boolean;
begin
a := 0;
while lines <> nil do
begin
err := false;
v := toInt(lines^.text, err);
if not err then
a := a + v;
lines := lines^.next;
end;
sumOfLines := a;
end;
これは、linesを一行一行確認しながら、文字列を整数に変換し、足し算しています。文字列を整数に変換するtoInt関数も、標準Pascalにはないので、プログラム内で定義しています。また、数値以外の文字列が来た時は、エラーにならないよう、第二引数のerrで判定するようにしています。(そのため、この第二引数は、参照呼び出しです)
プログラム全体では、次のようになります。
program CalcSumApp;
type
ItemPtr = ^Item;
Item =
record
next: ItemPtr;
text: ShortString;
end;
function toInt(str: ShortString; var err: Boolean): Integer;
var
i : Integer;
n : Integer;
r : Integer;
begin
r := 0;
for i := 1 to ord(str[0]) do
begin
n := Ord(str[i]);
if (n < Ord('0')) or (n > Ord('9')) then
begin
err := true;
break;
end;
r := r * 10 + n - Ord('0');
end;
toInt := r;
end;
function toStr(n: Integer): ShortString;
var
m : Integer;
s : ShortString;
begin
s := '';
while n > 0 do
begin
m := n mod 10;
n := n div 10;
s := chr(m + ord('0')) + s;
end;
if s = '' then
s := '0';
toStr := s;
end;
procedure disposeAll(ptr: ItemPtr);
begin
if ptr <> nil then
begin
disposeAll(ptr^.next);
end;
end;
function readLines(): ItemPtr;
var
result: ItemPtr;
prevItemPtr: ItemPtr;
currItemPtr: ItemPtr;
line: ShortString;
begin
result := nil;
prevItemPtr := nil;
while true do
begin
write('>');
readln(line);
if line <> '' then
begin
if prevItemPtr = nil then
begin
new(prevItemPtr);
prevItemPtr^.text := line;
prevItemPtr^.next := nil;
result := prevItemPtr;
end
else
begin
new(currItemPtr);
currItemPtr^.text := line;
currItemPtr^.next := nil;
prevItemPtr^.next := currItemPtr;
prevItemPtr := currItemPtr;
end;
end
else
break;
end;
readLines := result;
end;
function sumOfLines(lines: ItemPtr): Integer;
var
a : Integer;
v : Integer;
err: Boolean;
begin
a := 0;
while lines <> nil do
begin
err := false;
v := toInt(lines^.text, err);
if not err then
a := a + v;
lines := lines^.next;
end;
sumOfLines := a;
end;
var
lines : ItemPtr;
sum : Integer;
begin
writeln('数を入力して下さい。空行で終了です。');
lines := readLines();
sum := sumOfLines(lines);
writeln('合計は' + toStr(sum) + 'です。');
disposeAll(lines);
end.
Javaでの実装
Javaでは、二つのクラス、CalcSumApp
クラスとCalcSum
クラスで実装してみます。まずは、アプリケーション全体のCalcSumApp
クラスです。
public class CalcSumApp {
public static void main(String args[]) {
CalcSum cs = new CalcSum();
CalcSumApp app = new CalcSumApp();
app.start(cs);
}
public void start(CalcSum cs) {
System.out.println("数を入力して下さい。空行で終了です。");
cs.readLines();
int sum = cs.getSum();
System.out.println("合計は" + String.valueOf(sum) + "です。");
}
}
main
関数内で、CalcSumApp
クラスとcalcSum
クラスのインスタンスを作成して、 start
メソッドを呼んでいます。
start
メソッドの内容は、Pascalのメインルーチンとほぼ同じことをしていますが、直接データを扱うことはせず、CalcSum
クラスのオブジェクトを操作しているだけです。
次に、CalcSum
クラスは次のようになります。
class CalcSum {
List<String> list;
CalcSum () {
list = new ArrayList<String>();
}
public void readLines() {
BufferedReader input = new BufferedReader(new InputStreamReader(System.in));
String line;
try {
do {
System.out.print('>');
line = input.readLine();
if ("".equals(line)) {
break;
}
list.add(line);
} while (true);
} catch (IOException e) {
}
}
public int getSum() {
int sum = 0;
for (String s : list) {
try {
sum += Integer.valueOf(s).intValue();
} catch (NumberFormatException e) {
}
}
return sum;
}
}
コンソールから入力を受け付けるreadLines
メソッドと、総和を計算するgetSum
メソッドが定義されています。中身は、pascalのときのreadLines
関数、sumOfLines
関数とほぼおなじです。ただし、入力された数値データ(list)は、CalcSum
クラスのインスタンス変数として保持しています。
プログラム全体では、次のようになります。Pascalと比べると、toIntやtoStrのような変換処理や、メモリ解放のdisposeAllといった処理がないだけすっきりしていますし、また、listがCalcSumの中に収まっているのがいい感じです。(private
にした方がもっと良いかもしれませんが)
import java.util.Scanner;
import java.util.List;
import java.util.ArrayList;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.BufferedReader;
import java.io.IOException;
import java.lang.NumberFormatException;
class CalcSum {
List<String> list;
CalcSum () {
list = new ArrayList<String>();
}
public void readLines() {
BufferedReader input = new BufferedReader(new InputStreamReader(System.in));
String line;
try {
do {
System.out.print('>');
line = input.readLine();
if ("".equals(line)) {
break;
}
list.add(line);
} while (true);
} catch (IOException e) {
}
}
public int getSum() {
int sum = 0;
for (String s : list) {
try {
sum += Integer.valueOf(s).intValue();
} catch (NumberFormatException e) {
}
}
return sum;
}
}
public class CalcSumApp {
public static void main(String args[]) {
CalcSum cs = new CalcSum();
CalcSumApp app = new CalcSumApp();
app.start(cs);
}
public void start(CalcSum cs) {
System.out.println("数を入力して下さい。空行で終了です。");
cs.readLines();
int sum = cs.getSum();
System.out.println("合計は" + String.valueOf(sum) + "です。");
}
}
この例では簡単なので、CalcSumを不変オブジェクトにする実装も出来ると思うのですが、一般にオブジェクト指向プログラミングでは、不変に出来るオブジェクトは限られていると思うので、不変オブジェクト化した実装については触れませんでした。
Haskellでの実装
Haskellでも、メインルーチンはだいたい同じような感じで、次のようになりました。
main :: IO ()
main = do
hSetBuffering stdout NoBuffering
putStrLn "数を入力して下さい。空行で終了です。"
lines <- readLines getLines
list <- sequence lines
let s = sum list
putStrLn $ "合計は" ++ (show s) ++ "です。"
hSetBuffering stdout NoBuffering
の部分は、この記事のコメントでご指摘いただいたところで、これを入れないと、バッファリングにより入力と出力が期待した順序になることが保証されないということでした。( @igrep さん、ありがとうございます)
getLines
は、文字列を無限に受け取るリストで、次のように定義しています。
getLines :: [IO String]
getLines = do
repeat $ putStr ">" >> getLine
repeat
は、無限リストを作り出します。つまり、
repeat x = [x x x x .... x ... ]
という感じです。
このような無限リストが扱えるのはHakellの遅延処理の特徴で、これを使ってみたくこのようにしてみました。
readLines
は、空行が入力されるまでの部分を切り出す処理で、次のようになります。
readLines :: [IO String] -> IO [IO Int]
readLines [] = return []
readLines (l:ls) = do
v <- l :: IO String
if v == ""
then return []
else do
ll <- readLines ls
case readMaybe v of
Just x -> return $ (return x):ll
Nothing -> return ll
配列の第一要素が空文字なら、それで処理を終了。そうでなければ、
第一要素を数値に変換(readMaybe
)して、変換できれば、それに残りを変換したもの(再帰処理させたもの)を足して返し、変換出来なければ、残りを変換したもの(再帰処理させたもの)だけを返しています。
list <- sequence lines
ここはちょっと分かりにくですが、IOの配列を、配列のIOに変換して、その中身を取り出しています。つまりlist
は、ただの数値の配列になります。
ここは元々は
list <- mapM (>>= return) lines
としてたのですが、コメントで指摘されたように、squence
で一発で解決出来るので、変更しました。
そして、合計を計算するのは、次で行っています。
let s = sum list
(正格評価の話は、私の理解が間違っていたので割愛します)
プログラム全体では次のようになります。モナドの変換がちょっと面倒なのと、Javaと違って変数lines
を直接扱っていますが、do構文を使うことでpascalっぽい感じで記述することが出来ています。
module CalcSumApp where
import Text.Read
import System.IO
getLines :: [IO String]
getLines = do
repeat $ putStr ">" >> getLine
readLines :: [IO String] -> IO [IO Int]
readLines [] = return []
readLines (l:ls) = do
v <- l :: IO String
if v == ""
then return []
else do
ll <- readLines ls
case readMaybe v of
Just x -> return $ (return x):ll
Nothing -> return ll
main :: IO ()
main = do
hSetBuffering stdout NoBuffering
putStrLn "数を入力して下さい。空行で終了です。"
lines <- readLines getLines
list <- sequence lines
let s = sum list
putStrLn $ "合計は" ++ (show s) ++ "です。"
おまけ Haskell その2
Haskellでも、オブジェクト指向っぽく、データタイプを作ってカプセル化(っぽく)してみました。実用的ではないと思いますが、、。
メインルーチンは
main :: IO ()
main = do
hSetBuffering stdout NoBuffering
putStrLn "数を入力して下さい。空行で終了です。"
calcSum <- newCalcSum -- calcSum = new CalcSum()
inputStrings calcSum -- calcSum.inputString()
sum <- getSum calcSum -- sum = calcSum.getSum()
putStrLn $ "合計は" ++ (show sum) ++ "です。"
こうなっていて、Javaに近くなっていると思います。
コード全体では、
module CalcSumApp2 where
import Text.Read
import Control.Monad
import Data.IORef
import Data.Maybe
import System.IO
data CalcSum = CalcSum { getStrings :: IORef [String] }
newCalcSum :: IO CalcSum
newCalcSum = do
ref <- newIORef []
return $ CalcSum ref
inputStrings :: CalcSum -> IO ()
inputStrings calcSum = do
let truncateStrings :: [IO String] -> IO [String]
truncateStrings [] = return []
truncateStrings (x:xs) = do
s <- x
if s == ""
then
return []
else do
l <- truncateStrings xs
return $ (:l) $! s -- strict evaluation of s corresponding to `return (s:l)`
list <- truncateStrings $ repeat $ putStr ">" >> getLine
writeIORef (getStrings calcSum) list -- calcSums.strings = list
getSum :: CalcSum -> IO Int
getSum calcSum = do
list <- readIORef (getStrings calcSum) :: IO [String] -- list = calcSum.strings
let nums = catMaybes $ readMaybe <$> list :: [Int]
return $ sum nums
main :: IO ()
main = do
hSetBuffering stdout NoBuffering
putStrLn "数を入力して下さい。空行で終了です。"
calcSum <- newCalcSum -- calcSum = new CalcSum()
inputStrings calcSum -- calcSum.inputString()
sum <- getSum calcSum -- sum = calcSum.getSum()
putStrLn $ "合計は" ++ (show sum) ++ "です。"
ここで言いたかったのは、オブジェクト(的なもの)をIORef
にしたことです。そもそもオブジェクトはIOとは関係ないものだと思うのですが、自分の中ではオブジェクトは、アプリケーションとは別に、ヒープメモリに領域を確保して読み書きするもの(DBみたいなイメージ)なので、IORef
にするとしっくり来るように思いました。
オブジェクト指向言語のオブジェクトは、IORef的なもの(共有されて、変更可能なもの)以外に、共有されないオブジェクトや、不変オブジェクト、不変でかつ副作用もない(IO入出力がないなど)オブジェクト、副作用の大きなオブジェクト(外部サーバーとやりとりする処理をラップしたオブジェクトとか)など、いろいろなものがあると思うのですが、そのあたりをうまくまとめた考察が出来なかったので、ここではこのサンプルを提示するだけにとどめておきます。
感想
標準Pascalは機能が少なくて、toInt
や toStr
を自分で実装しなくてはいけなかったり、あるいは、メモリの解放を自分でやる必要があるのですが、こうした(面倒な)処理を書いていると、「あ〜、プログラミングしているなあ」という気持ちになれるのが好きです。
なんていうか、オートマ車じゃなくてマニュアル車をいじっている感じがするというか。最近では、Goとかがこういう感じなんですかね。
Javaは、いろいろ批判されることはあるものの、やっぱり使いやすくて良い言語だと思います。オブジェクト指向プログラミング言語は、Rubyのようにメタプログラミング出来たり、JavaScriptのように動的にオブジェクトを作れたりする方が向いているような気もしますが、でも、静的にクラスが決まる安心感はいいですね。自分はJava 1.5くらいが好きで、あまり他のパラダイムが組み込まれて複雑になってしまうのは残念です。
Haskellは、まだまだ勉強することがいっぱいで、苦戦していますが、動作した時はすごく嬉しいです。関数がたくさんあって理解するのも覚えるのも大変なのですが、使い所が分かるとおおっという感じがするのと、静的に解決している感じがよいですね。型に注意するようになるのも良い気がします。
おわりに
この簡単な例では、どの実装でもあまり変わらないのですが、それでも各言語の特徴はいくつか出ているのではないかな、と思いました。
最近のプログラミング言語は、マルチパラダイムなものが増えていますが、だからこそ、シングルパラダイムの言語の基本を学ぶのもの大事だと思っています。
間違った説明や、不十分な点など、ありましたら、ぜひともコメントお願いします。