関数型プログラミングはまずは純粋関数型言語を用いて、考え方から理解しよう
この記事は、関数型プログラミングはまず考え方から理解しよう
の記事を純粋関数型言語Elmで書き換え、一部の文章について批判的に言及させていただいた記事になります。この記事を書こうと思ったきっかけとしては、今回参考にさせていただきた記事が過去に書かれたものにも関わらず、今に渡っても見られていそうなこと。未だにパラダイムの理解に関する誤解が多く散見されること。改めて純粋関数型言語の実用性・有用性について、見直されるべきだと思い、この記事を執筆させていただきました。
次のステップアップ記事は、[超入門] FizzBuzzで考える関数型プログラミング学習を純粋関数型言語でやる理由です。
はじめに、この記事の主張を結論としてまとめておきます。
- 対比すべきは、関数型プログラミングとオブジェクト指向プログラミングではなく、関数型プログラミングと手続き型プログラミングである
- 関数型プログラミングの考えを学ぶには、純粋関数型言語で学ぶべきである
- すべてのパラダイムに優劣は作りたいものによって変わる、そのため、すべてを学び、使い分けるべきである
対比すべきは、関数型プログラミングとオブジェクト指向プログラミングではなく、関数型プログラミングと手続き型プログラミングである
一つ目のテーマは、「対比すべきは、関数型プログラミングとオブジェクト指向プログラミングではなく、関数型プログラミングと手続き型プログラミングである」で見ていこうと思います。元の記事では、オブジェクト指向プログラミングのコードについて
お弁当スーパークラスを作って継承で唐揚げ弁当クラスを、というように拡張を考えた設計にするというのも汎用性が必要な場合は良いと思います。
という風に記述されています。が、特にスーパークラス・サブクラスの考えは反映されたコードにはなっていません(2016年なので、ES2015がまだ浸透していなかったため、そのようなコードになっていないという可能性もありますが)。また、classベースのオブジェクト指向プログラミングが書けたとしても、現在では(あまり考え無しに)継承をするのは避けましょう、合成を使いましょう。というのはEffective Javaなどが割と大昔から提唱しています。
また、関数型コードの説明では、ループをmapやreduceに置き換えましょう。副作用を分離しましょう。と述べられているように、手続き型的な書き方を減らしましょうという主張に見えます。
もしかしたら、このような元記事の指摘に関しては、元記事の例自体に問題があり、オブジェクト指向と関数型の対比も存在するのではないか?と思われるかもしれません。しかし、先ほども述べたとおり、オブジェクト指向プログラミングでは継承をなるべく避けたり、なるべくイミュータブルにオブジェクトを設計したり、関数型プログラミングとオブジェクト指向プログラミングの境目は無くなってきています。
決定的な考え方として、世の中には関数型オブジェクト指向プログラミングという2つのパラダイムを調和させプログラミングしていく言語が存在します。その一つとして、Scalaが存在します。Scalaは関数をオブジェクトとして定義することで、オブジェクト指向上で関数型プログラミングをエミュレートすることに成功しています。Java8でも関数はFunctionalInterfaceを用いて表現することで関数型プログラミングを行っています。
文章だけでは、イメージしにくいと思いますので、例の唐揚げ問題をScalaで解いたらどうなるかを見ていきましょう。ここでいじいじすることができます。
弁当を表すclass Bento
や唐揚げ弁当の群を定義して、1番多い唐揚げ弁当からつまみ食いするためのclass BentoList
を定義するのはオブジェクト指向の考えですが、中のロジックでは再帰関数を定義したり、mapやforallなどの高階関数を利用しています。見事に、オブジェクト指向と関数型プログラミングが融合してるのが見て取れることでしょう。
case class Bento(dish: String, num: Int)
// 弁当群を扱うクラス
case class BentoList(value: Seq[Bento], eatCount: Int = 0) {
def eat: BentoList = {
val maximum = findMaxNum
// 先頭から一つずつ弁当を取り出し、唐揚げの個数が最大値の弁当を見つけたら食べる
def loop(bentoList: Seq[Bento]): Seq[Bento] =
bentoList match {
// 空の場合は空
case Seq() => Seq()
case bento +: rest =>
// 唐揚げの個数が最大値であれば、唐揚げを一つ減らした弁当に差し替え、残りをまた並べる
if (bento.num == maximum) bento.copy(num = bento.num - 1) +: rest
// 最大値でなければ、残りの弁当群について見ていく
else bento +: loop(rest)
}
if (isEmpty) {
println("もう食べれない。")
this
} else
this.copy(value = loop(value), eatCount = this.eatCount + 1)
}
// すべての弁当の唐揚げの個数が0ならば、空
def isEmpty: Boolean = value.forall(v => v.num == 0)
private def findMaxNum: Int =
value.map(v => v.num).max
}
val karaben1 = Bento("唐揚げ", 10)
val karaben2 = Bento("唐揚げ", 8)
val karaben3 = Bento("唐揚げ", 6)
// 3回つまみ食いをする
println(BentoList(Seq(karaben1, karaben2, karaben3)).eat.eat.eat)
最後に、classがあることと・無いことでオブジェクト指向と関数型プログラミングを対比できるじゃ無いか。という意見があるとしたら、モジュールと言うカプセル化を再現できたりする手法が関数型プログラミング言語にも備わっているケースが多いです。また、型クラスというインターフェースより強力な考え方を備えている言語も存在します。いずれにせよ、オブジェクト指向プログラミングと関数型プログラミングの優劣を厳密に見ていくことよりも、手続き的な書き方と関数型的な考え方を書き分ける方がより重要では無いかと思います。
関数型プログラミングの考えを学ぶには、純粋関数型言語で学ぶべきである
次の主張は、「関数型プログラミングの考えを学ぶには、純粋関数型言語で学ぶべきである」です。この話に入るにあたって、考えたい話が1つあります。それは、「オブジェクト指向プログラミング」とは何か? 「関数型プログラミング」とは何か? この問題に関して、個人的な見解を述べますと、両者のスタイルはマルチパラダイムな言語によって境界は無くなり曖昧になっているです。これはどういうことかと言うと、先述したようにオブジェクト指向プログラミングJavaは、登場当初こそオブジェクト指向と手続き型のパラダイムを持つマルチパラダイムな言語でしたが、Java8になりStream, FunctionalInterface, ラムダ式の導入に当たって、更に関数型プログラミングいうパラダイムを取り込んだと見ることができます。一方で、関数型プログラミング言語でモジュールや型クラスの考え方を使えば、オブジェクト指向に近いプログラミングスタイルを行うこともできてしまいます。そのため、もはや、言葉だけで正確にオブジェクト指向とは何か?関数型プログラミングとは何か?と説明するのがドンドン難しくなっています。その証拠に、このパラダイム議論は毎年・毎月の恒例行事のように話題がエンジニア界隈でループしています。そのため、この境界線を追い求め続けるのは、プログラミング言語学者や興味が強くある人だけでいいのでは無いかと思います。
一方で、関数型プログラミングの考えを学ばなくても良いのか?と言う問いに関しては、キチンと学ぶべきではないかと思います。上手く言葉の定義が明らかにできなかったとしても、テクニックやノウハウは学び取り、実戦に反映させることができます。その場合に、元記事のように慣れ親しんだ言語で関数型プログラミングの考え方を学ぶこともできますが、私は、マルチパラダイムな言語で関数型プログラミングを学ぶべきでは無いと思っています。なぜなら、コードを書いたときにそれは関数型プログラミングであるのか? そうでは無いのか?のジャッジマンは常にいないからです。もし、関数型プログラミングの考え方を理解していそうな指導者が近くにいたとしても、人間はミスを犯します。また、本当の意味で常にあなたの側にその人がいるとは限りません。しかし、あなたの慣れ親しんだ言語から離れれば、あなたの書いたコードが関数型であるかどうかを判断してくれる最高のジャッジマンがいます。それは純粋関数型言語のコンパイラです。
さらに、純粋関数型言語を用いて、関数型プログラミングを学ぶべき理由として、イミュータブルを前提としたデータ構造や関数が豊富に備わっている点です。非純粋関数型言語でも関数型ライブラリを利用すれば、ある程度、耐えて運用することができるかもしれませんが、関数型プログラミングを学ぶと言うことが目的にしたいのであれば、ライブラリを探すことが目的になってはいけませんし、それが障壁になって手続き型の書き方にせざるを得なくなってしまっては元も子もありません。例えば、Elmでは、以下のデータ構造が標準であり豊富にデータ構造に基づく関数が用意されています。(ごく一部です。)ここまで、揃ってしまえば、基本的にやりたいことは、関数を組み合わせるだけで大半の処理は書けてしまい、ライブラリを使う必要はありません。
それでは、唐揚げプログラミング例を以下に載せます。コンパイルが通っているコードなので、ロジックの途中に副作用が差し込まれたりすることも、継承によるプログラミングも行っていないことが保証されています。これは、どなたが書いたとしても、これらのことは保証され続けます。
ここからいじいじすることができます。
テストコード付きのリポジトリは、こちら
この記事では考え方のみを解説するため、コードの解説は省略します。もし要望があれば、別途コード解説記事を載せようと思います。
module Main exposing (Msg(..), eatBento, findMaxNum, update)
import Browser
import Html exposing (Html, button, div, li, span, text, ul)
import Html.Events exposing (onClick)
type alias Bento =
{ dish : String, num : Int }
type alias Model =
{ bentoList : List Bento, eatCount : Int }
initialModel : Model
initialModel =
let
karaben1 =
{ dish = "唐揚げ", num = 10 }
karaben2 =
{ dish = "唐揚げ", num = 8 }
karaben3 =
{ dish = "唐揚げ", num = 6 }
in
{ bentoList = [ karaben1, karaben2, karaben3 ], eatCount = 0 }
type Msg
= Eat
-- 唐揚げ弁当群から個数のみを取り出したリストにし、そのうちの最大値を取る。空の場合は0を最大値とする
findMaxNum : List Bento -> Int
findMaxNum bentoList =
bentoList |> List.map (\bento -> bento.num) |> List.maximum |> Maybe.withDefault 0
-- 弁当群と最大値を渡すと、最大個数の唐揚げ弁当から1つ唐揚げを食べたものに差し替える
eatBento : List Bento -> Int -> List Bento
eatBento bentoList maximumNum =
case bentoList of
-- 空であれば食べない
[] ->
[]
-- 1つずつ弁当を取っていく
bento :: rest ->
-- 唐揚げの個数が最大値であるならば、弁当から唐揚げを一つ取って食べる。残りを並べる
if bento.num == maximumNum then
{ bento | num = bento.num - 1 } :: rest
else
-- 最大値で無ければ、残りの弁当群についてみていく
bento :: eatBento rest maximumNum
update : Msg -> Model -> Model
update msg model =
case msg of
-- もし、つまみ食いの命令が来たならば
Eat ->
let
-- 弁当群から唐揚げの最大値を計算する
maximumNum =
findMaxNum model.bentoList
in
-- 最大値が0なら食べない
if maximumNum == 0 then
model
else
-- 最大値が1以上なら、弁当を食べて、食べた回数をふやす
{ model | bentoList = eatBento model.bentoList maximumNum, eatCount = model.eatCount + 1 }
-- すべての弁当群の唐揚げの個数が0なら空っぽ
isEmptyBentoList : List Bento -> Bool
isEmptyBentoList bentoList =
List.all (\bento -> bento.num == 0) bentoList
view : Model -> Html Msg
view model =
div []
-- つまみ食いボタン
[ button [ onClick Eat ] [ text "食べる" ]
, span []
[ text <|
" "
++ String.fromInt model.eatCount
++ "個食べた。"
++ (if isEmptyBentoList model.bentoList then
"もう食べれない。"
else
""
)
]
-- 弁当を並べて数える
, ul [] <|
List.map
(\bento ->
li [] [ text <| bento.dish ++ String.fromInt bento.num ++ "個" ]
)
model.bentoList
]
main : Program () Model Msg
main =
Browser.sandbox
{ init = initialModel
, view = view
, update = update
}
改めて、この節での結論ですが、純粋関数型プログラミングによる関数型言語の学び方は、慣れ親しんだ言語で学ぶのに比べて、明らかに楽ではありません。しかし、あなたを確実に関数型プログラミングを学んでいるかどうかをジャッジしてくれるコンパイラが付いてきます。それでも、厳しいと感じる場合は、次の言葉を心に刻み込んでで見てください。新しい知識を学ぶのは決してどんな時でも楽ではありません。知らないことを知ろうとするのですから、大なり小なり苦痛や息苦しさを感じるのは当たり前です。しかし、あなたが知識を学び取り込み、よりよく生かそうとするのであるならば、確実に最短経路となるでしょう。
すべてのパラダイムに優劣は作りたいものによって変わる、そのため、すべてを学び、使い分けるべきである
ごく当たり前のことを述べています。これも大昔から言われていることですが、銀の弾丸なんてものは存在しません。そして、多くの場合にはトレードオフが存在します。例えば、手続き的プログラミングで生じるバグなどを防ぎたい場合、関数型プログラミングを用いて状態を減らし、コンパイルによる強力なチェックを設けたい場合、当然、コンピュータのリソースを最大限生かしたパフォーマンスを得ることは出来ませんし、複雑なチェックを掛ければ掛けるほど、コンパイル時間は犠牲となります。あなたのチームメンバーの力量と物づくりに与えられた期間、必要とされるパフォーマンス・使えるリソース。作りたいものは何なのかを考えた上で、プログラミング言語を使い分けてください。私は、Elm, Rust, TypeScript, Scala, Python等を趣味や仕事で使い分けています。パラダイムもパフォーマンスもコンパイラ・インタプリタもビルド方法も考え方もバラバラです。しかし、その時々で概ね最大の(ものづくりとしての)パフォーマンスを発揮します。
純粋関数型プログラミング言語を選ぶ最大の使い分け理由を選ぶとしたら、とにかく堅牢でテストが書きやすいことです。皆さんはテスト駆動開発を学んで、それを実戦でできているでしょうか? もし、それができていないのであれば、もしかしてテストを書ける状態に持っていくことが一苦労だからでは無いでしょうか? 例えばElmでは、コンパイルが通ればロジックに対していつでもテストを記述することが可能であることを保証します。もし、プロダクトが堅牢であること前提で進めたいプロダクトであるならば、逆説的にElmは最大のパフォーマンスを発揮します。
唐揚げプログラムに対するテストは以下のように書けます。
findMaxNumTest : Test
findMaxNumTest =
let
bento =
{ dish = "唐揚げ", num = 0 }
in
describe "findMaxNum test"
[ describe "Bento Listが空のとき"
[ test "最大値が0になる" <|
\_ ->
[]
|> findMaxNum
|> Expect.equal 0
]
, describe "numが3, 1, 2の唐揚げ弁当があるとき"
[ test "最大値が3になる" <|
\_ ->
[ { bento | num = 3 }, { bento | num = 2 }, { bento | num = 1 } ]
|> findMaxNum
|> Expect.equal 3
]
, describe "numが1, 1, 1の唐揚げ弁当があるとき"
[ test "最大値が1になる" <|
\_ ->
[ { bento | num = 1 } ]
|> findMaxNum
|> Expect.equal 1
]
]
eatBentoTest : Test
eatBentoTest =
let
bento =
{ dish = "唐揚げ", num = 0 }
in
describe "eatBento test"
[ describe "Bento Listが空のとき"
[ test "空のリストになる" <|
\_ ->
eatBento [] 0
|> Expect.equal []
]
, describe "numが3, 2, 1の唐揚げ弁当があるとき" <|
let
bentoList =
[ { bento | num = 3 }, { bento | num = 2 }, { bento | num = 1 } ]
in
[ test "numが2, 2, 1の唐揚げ弁当になる" <|
\_ ->
eatBento bentoList (findMaxNum bentoList)
|> Expect.equal [ { bento | num = 2 }, { bento | num = 2 }, { bento | num = 1 } ]
]
, describe "numが2, 2, 1の唐揚げ弁当があるとき" <|
let
bentoList =
[ { bento | num = 2 }, { bento | num = 2 }, { bento | num = 1 } ]
in
[ test "numが1, 2, 1の唐揚げ弁当になる" <|
\_ ->
eatBento bentoList (findMaxNum bentoList)
|> Expect.equal [ { bento | num = 1 }, { bento | num = 2 }, { bento | num = 1 } ]
]
]
updateTest : Test
updateTest =
let
bento =
{ dish = "唐揚げ", num = 0 }
in
describe "update test"
[ describe "まだ全てが空っぽでない唐揚げ弁当群を一つ食べると"
[ test "eatCountが1増え、もっとも唐揚げが多い弁当から一つ唐揚げが減る" <|
\_ ->
{ bentoList = [ { bento | num = 3 }, { bento | num = 2 }, { bento | num = 1 } ], eatCount = 0 }
|> update Eat
|> Expect.equal { bentoList = [ { bento | num = 2 }, { bento | num = 2 }, { bento | num = 1 } ], eatCount = 1 }
]
, describe "全てが空っぽである唐揚げ弁当群を一つ食べようとすると"
[ test "何も変わらない" <|
\_ ->
{ bentoList = [ { bento | num = 0 }, { bento | num = 0 }, { bento | num = 0 } ], eatCount = 6 }
|> update Eat
|> Expect.equal { bentoList = [ { bento | num = 0 }, { bento | num = 0 }, { bento | num = 0 } ], eatCount = 6 }
]
, describe "空っぽを一つ食べようとすると"
[ test "何も変わらない" <|
\_ ->
{ bentoList = [], eatCount = 0 }
|> update Eat
|> Expect.equal { bentoList = [], eatCount = 0 }
]
]
まとめ
一つの記事をきっかけに、個人的な考えを広げ発信することができました、批判的な内容を含んでいましたが、大きなきっかけとなったので、あたらめてstkdevさんありがとうございました。私自身の考え方を大いに述べさせていただきましたが、考え方や学び方は千差万別です。是非、これをきっかけにいろんな人が思考を巡らせる結果になり、為になればなと思います。コメントでもTwitterでの絡みなどいつでもお待ちしております。
Discussion