より具体的には、「C や Java などの命令型言語で既にプログラミングしている場合、関数型プログラミングの考え方をどのように調整すればよいですか?」という意味であなたの質問を解釈します。あなたの問題を例として、土曜日の朝にこの質問に長い形式で答えるつもりです。関数型プログラマーの進化を 3 つの段階でたどっていきます。それぞれの段階は、禅の段階的に高くなります。1)反復的に考えます。2)再帰的に考える。3)怠惰に考える。
パート I - 反復的に考える
私が C でプログラミングしていて、再帰を使用できない、または使用しないとします。おそらく、コンパイラは末尾再帰を最適化せず、再帰的なソリューションはスタックをオーバーフローさせます。それで、私はどのような状態を維持する必要があるかを考え始めます。入力の上を這う小さな機械を想像してみてください。増加するシーケンスまたは減少するシーケンスを検索しているかどうかを記憶します。まだ決定していない場合は、可能であれば現在の入力に基づいて決定します。間違った方向に向かう入力が見つかった場合、zigzag=true で終了します。入力の最後に到達すると、zigzag=false で終了します。
int
zigzag(int *data, int n)
{
enum {unknown, increasing, decreasing} direction = unknown;
int i;
for (i = 1; i < n; ++i)
{
if (data[i] > data[i - 1]) {
if (direction == decreasing) return 1;
direction = increasing;
}
if (data[i] < data[i - 1]) {
if (direction == increasing) return 1;
direction = decreasing;
}
}
/* We've made it through the gauntlet, no zigzagging */
return 0;
}
このプログラムは典型的な C プログラムです。効率的ですが、正しいことを証明するのは困難です。この単純な例であっても、これが無限ループに陥ったり、ロジックのどこかで間違った方向に進んだりする可能性があるかどうかはすぐにはわかりません。もちろん、より複雑なプログラムではさらに悪化します。
パート II - 再帰的に考える
関数型言語の精神で読みやすいプログラムを作成するための鍵は (単に命令型ソリューションをその言語に変形させようとするのではなく) 、プログラムがどのように計算するかではなく、何を計算するかに焦点を当てることであることがわかりました。十分な精度でそれを行うことができれば (問題を明確に書き出すことができれば)、ほとんどの場合、関数型プログラミングでは、ほぼ解決に近づいています!
それでは、計算することをより詳細に書き出すことから始めましょう。リストがジグザグ (つまり、ある時点で減少し、別の時点で増加する) かどうかを知りたいのです。この基準を満たすリストはどれですか? さて、次の場合、リストはジグザグになります。
- 2 要素以上の長さであり、かつ
- 最初は増加しますが、ある時点で減少します OR
- 最初は減少するが、ある時点で増加する OR
- その尾はジグザグです。
上記のステートメントを多かれ少なかれ直接的にScheme関数に変換することが可能です:
(define (zigzag xs)
(and (> (length xs) 2)
(or (and (initially-increasing xs) (decreases xs))
(and (initially-decreasing xs) (increases xs))
(zigzag (cdr xs)))))
ここで、 、 、 、および の定義initially-increasing
がinitially-decreasing
必要decreases
ですincreases
。initially-
関数は十分に単純です。
(define (initially-increasing xs)
(> (cadr xs) (car xs)))
(define (initially-decreasing xs)
(< (cadr xs) (car xs)))
decreases
とはどうincreases
ですか?シーケンスの長さが 1 より大きく、最初の要素が 2 番目の要素より大きい場合、または末尾が減少する場合、シーケンスは減少します。
(define (decreases xs)
(letrec ((passes
(lambda (prev rest)
(cond ((null? rest) #f)
((< (car rest) prev)
#t)
(else (passes (car rest) (cdr rest)))))))
(passes (car xs) (cdr xs))))
同様の関数を書くこともできますが、必要increases
な変更は 1 つだけであることは明らかです。非常に多くのコードを複製すると、不安になるはずです。言語に のような関数を作成するように依頼できませんでしたが、代わりにその場所で使用していますか? 関数型言語では、関数は他の関数を返すことができるため、まさにそれを行うことができます! したがって、実装する関数を書くことができます。<
>
decreases
>
(define (ever op)
(lambda (xs)
(letrec ((passes
(lambda (prev rest)
(cond ((null? rest) #f)
((op (car rest) prev)
#t)
(else (passes (car rest) (cdr rest)))))))
(passes (car xs) (cdr xs)))))
increases
両方ともdecreases
非常に簡単に定義できるようになりました。
(define decreases (ever <))
(define increases (ever >))
実装する関数はもうありません - 完了です。このバージョンが C バージョンよりも優れていることは明らかです。このプログラムが正しいことを行うと判断するのははるかに簡単です。このプログラムのほとんどは非常に簡単で、すべての複雑さが関数に押し込まれていever
ます。これは、他の多くのコンテキストで役立つ非常に一般的な操作です。検索することで、このカスタム実装ではなく、標準の (したがってより信頼できる) 実装を見つけることができると確信しています。
改善はされていますが、このプログラムはまだ完全ではありません。多くのカスタム再帰があり、最初はそのすべてが末尾再帰であることは明らかではありません(実際にはそうですが)。また、プログラムは複数の条件付き分岐と出口点の形で C のかすかなエコーを保持します。遅延評価の助けを借りて、さらに明確な実装を得ることができます。そのために、言語を切り替えます。
パート III - 怠惰に考える
問題の定義に戻りましょう。実際には、パートIIよりもはるかに簡単に述べることができます-「両方向に進む隣接する要素間の比較が含まれている場合、シーケンスはジグザグになります(つまり、ソートされません)」。この文を多かれ少なかれ直接的に Haskell の行に翻訳できます。
zigzag xs = LT `elem` comparisons && GT `elem` comparisons
ここで、 のすべてのメンバーとその後継comparisons
メンバーとの比較のリストであるを導出する方法が必要です。xs
これは難しいことではなく、例によって説明するのがおそらく最も適切です。
> xs
[1,1,1,2,3,4,5,3,9,9]
> zip xs (tail xs)
[(1,1),(1,1),(1,2),(2,3),(3,4),(4,5),(5,3),(3,9),(9,9)]
> map (\(x,y) -> compare x y) $ zip xs (tail xs)
[EQ,EQ,LT,LT,LT,LT,GT,LT,EQ]
必要なのはそれだけです。これらの 2 行は完全な実装です -
zigzag xs = LT `elem` comparisons && GT `elem` comparisons
where comparisons = map (\(x,y) -> compare x y) $ zip xs (tail xs)
このプログラムは、リストを 1 回だけ通過させて、増加するケースと減少するケースの両方をテストすることに注意してください。
ここまでで、おそらく反論を考えたことがあるでしょう。このアプローチは無駄ではありませんか? 最初の方向転換まで行けばよいのに、これは入力リスト全体を検索するのではないでしょうか? 実際には、遅延評価のため、そうではありません。上記の例では、印刷するために比較リスト全体を計算する必要があったためです。しかし、結果を に渡す場合は、 の 1 つのインスタンスとの 1zigzag
つを見つけるのに十分なだけ比較リストを評価し、それ以上は評価しません。これを確信するには、次のケースを検討してください。GT
LT
> zigzag $ 2:[1..]
True
> zigzag 1:[9,8..]
True
どちらの場合も、入力は無限リスト ([2,1,2,3,4,5..] および [1,9,8,7,6,5...]) です。それらを印刷しようとすると、画面いっぱいになります。しかし、それらを に渡すzigzag
と、方向の最初の変更を見つけるとすぐに、非常に迅速に戻ります。
コードを読むのが難しいのは、多くの場合、制御フローの複数の分岐をたどるからです。そして、これらのブランチの多くは、必要以上の計算を避けるための努力です。しかし、同じことの多くは遅延評価でも達成でき、プログラムをより短くし、元の質問により忠実にすることができます。