Slide 1

Slide 1 text

関数プログラミングと再帰 T.T 1

Slide 2

Slide 2 text

自己紹介 T.T(ティーツー) 株式会社ユーザベース 新卒入社して2年が経ちました 好きな言語: F#/Clojure ※ 今回のコードは全てF#で書いていきます 2

Slide 3

Slide 3 text

今日は関数プログラミングと再帰について話します。 3

Slide 4

Slide 4 text

本講演のゴール 再帰の基本概念を理解する 再帰の最適化手法を体験する 4

Slide 5

Slide 5 text

そもそも再帰って? 関数内で自分自身を呼び出すことを再帰呼び出しといい、再帰呼び出しを含む関数の ことを再帰関数という。 let rec sum lst = match lst with | [] -> 0 | first :: rest -> first + sum rest 5

Slide 6

Slide 6 text

再帰関数の作り方 ベースケース: 再起呼び出しをせずに手元にある情報のみから答えが計算できるケ ース 再帰ステップ: より小さな部分問題に対して再帰呼び出しを行う。 let rec sum lst = match lst with | [] -> 0                 // ベースケース | first :: rest -> first + sum rest // 再帰ステップ 6

Slide 7

Slide 7 text

なぜ再帰? 再帰は関数プログラミングにおいて重要な概念。 関数プログラミングではループ構文の代わりに再帰が使われることが多い。 7

Slide 8

Slide 8 text

手続き型プログラミングの場合 手続き型プログラミングは構文を利用する 構文は値を返さず、副作用を起こすもの。 ループ構文を使う場合、ミュータブルな値が必要になる。 構文の特徴: 値を返さない 状態を変更する // 手続き型 function factorial(n) { let result = 1; for (let i = 1; i <= n; i++) { result = result * i; } return result; } 8

Slide 9

Slide 9 text

関数プログラミングの場合 関数プログラミングでは式を中心とした記述を行う。 式の特徴: 値を返す 副作用がない 同じ入力に対してはいつでも常に同じ結果を返す (参照透過性を持つ) 9

Slide 10

Slide 10 text

また、関数型言語では一度作ったデータは変わることがなく、不変性を持つ。 このような特徴により、副作用を伴わない関数呼び出しが可能になり、繰り返し処理 もループ構文ではなく再帰によって表現することが一般的。 let rec factorial n = match n with | 0 -> 1 | _ -> n * factorial (n - 1) 10

Slide 11

Slide 11 text

Listのmapを実装して再帰について学んでみる まずListの定義を確認する リストの定義( 『プログラミングの基礎』より引用) 空リスト [] はリストである first が要素, rest がリストなら first :: rest もリストである。 例: [1; 2; 3] = 1 :: 2 :: 3 :: [] = 1 :: (2 :: (3 :: [])) 11

Slide 12

Slide 12 text

引数で受け取ったリストをパターンマッチする。 let map f lst = match lst with | [] -> failwith "TODO" | first :: rest -> failwith "TODO" 12

Slide 13

Slide 13 text

空のリストが渡された場合は、空のリストを返す let map f lst = match lst with | [] -> [] | first :: rest -> failwith "TODO" 13

Slide 14

Slide 14 text

リストが空でなかった場合は、先頭の要素をfirstとそれ以降の要素を含むリストに分割 する。 引数で受け取った関数をfirstに適用させる。 :: で f first の返り値と再帰呼出しして得た結果のリストを結合させる。 let rec map f lst = match lst with | [] -> [] | first :: rest -> (f first) :: (map f rest) 14

Slide 15

Slide 15 text

mapを実装できた!!だが問題がある この実装には問題がある。 let rec map f lst = match lst with | [] -> [] | first :: rest -> (f first) :: (map f rest) 15

Slide 16

Slide 16 text

再帰呼び出しがされるたびに関数がコールスタックに追加され、 今のままの実装では長いリストを扱う場合は、Stack overflowが発生してしまう。 16

Slide 17

Slide 17 text

17

Slide 18

Slide 18 text

そんなときに末尾再帰 末尾再帰とは 再帰呼出しが一つだけで、その呼び出しが関数の最後の処理(末尾呼び出し)になってい る再帰のパターンのこと。 言語や処理系によるが一般的に末尾再帰はコンパイラに再帰呼び出しを関数呼び出し ではなく、goto文(戻り先を保存しない飛び越し命令)に変換させる。goto文はスタック に追加されないので、Stack overflowを防ぐことができる。 これを末尾呼び出し最適化と呼ぶ。 18

Slide 19

Slide 19 text

アキュムレータを用いて末尾再帰でmapを実装する mapの中に補助関数として再帰関数を定義し、引数でアキュムレータを受け取れるよ うにする。 空のリストを受け取った場合は、アキュムレータを返すようにする。 それ以外のリストの場合はfにfirstを適用した値をアキュムレータに累積していく。 let map f lst = let rec mapInner lst acc = match lst with | [] -> acc | first::rest -> mapInner rest ((f first) :: acc) mapInner lst [] |> List.rev 19

Slide 20

Slide 20 text

やったか?.. 先ほどと同じ要素数でmapを呼び出してもStack overflowは発生しなくなった。 20

Slide 21

Slide 21 text

木構造の場合 このような単純な木構造があるとする type 'a Tree = | Leaf of 'a | Node of 'a Tree * 'a Tree 例: Node (Leaf 1, Node(Leaf 2, Leaf 3)) Node / \ Leaf(1) Node /  \ Leaf(2) Leaf(3) 21

Slide 22

Slide 22 text

木構造におけるmap リストと同じような形で末尾再帰ではないmapを書くとこうなる。 これを末尾再帰にする場合、この複数再帰呼び出しをしているとアキュムレータを使 ってもうまくいかない… let rec map f tree = match tree with | Leaf x -> f x |> Leaf | Node (left, right ) -> let leftResult = map f left let rightResult = map f right Node(leftResult, rightResult) 22

Slide 23

Slide 23 text

23

Slide 24

Slide 24 text

そんなときに継続渡しスタイル(CPS) 「次に何をするか」という関数を再帰呼び出しの引数に渡していく。 ここでいう cont は継続で「残りの計算」を表す。 これは末尾再帰になっていて、スタックには追加されない。 let map f tree = let rec mapInner tree cont = match tree with | Leaf x -> Leaf(f x) |> cont | Node (left, right) -> mapInner left (fun leftResult -> mapInner right (fun rightResult -> Node(leftResult, rightResult) |> cont)) mapInner tree id 24

Slide 25

Slide 25 text

Node (Leaf 1, Leaf 2) を例になにが起こっているのかを紐解いていく。 let map f tree = let rec mapInner tree cont = match tree with | Leaf x -> Leaf(f x) |> cont | Node (left, right) -> mapInner left (fun leftResult -> mapInner right (fun rightResult -> Node(leftResult, rightResult) |> cont)) mapInner tree id > map ((*) 2) (Node (Leaf 1, Leaf 2));; val it: int Tree = Node (Leaf 2, Leaf 4) 25

Slide 26

Slide 26 text

初回の再帰呼出し 補助関数の引数の状態 tree: Node (Leaf 1, Leaf 2) cont: id 初回はNodeのパターンに入る。 mapInner を left とそれ以降の計算を引数に再帰呼 出しをする let map f tree = let rec mapInner tree cont = match tree with | Leaf x -> Leaf(f x) |> cont | Node (left, right) -> //このケースに入る mapInner left (fun leftResult -> mapInner right (fun rightResult -> Node(leftResult, rightResult) |> cont)) mapInner tree id 26

Slide 27

Slide 27 text

2回目の再帰呼出し 補助関数の引数の状態 tree: Leaf 1 cont: (fun leftResult -> mapInner right (fun rightResult -> Node(leftResult, rightResult) |> id 2回目は Leaf のパターン。引数で受け取った関数 f を適用して cont を呼ぶ。 let map f tree = let rec mapInner tree cont = match tree with | Leaf x -> //このケースに入る Leaf(f x) |> cont | Node (left, right) -> mapInner left (fun leftResult -> mapInner right (fun rightResult -> Node(leftResult, rightResult) |> cont)) mapInner tree id 27

Slide 28

Slide 28 text

cont を呼び出した結果 Leaf 2 |> (fun leftResult -> mapInner right (fun rightResult -> Node(leftResult, rightResult) |> id)) mapInner right (fun rightResult -> Node(Leaf 2, rightResult) |> id) 28

Slide 29

Slide 29 text

3回目の再帰呼び出し。 補助関数の引数の状態 tree: Leaf 2 cont: (fun rightResult -> Node(Leaf 2, rightResult) |> id) 今回も Leaf のケースに入る let map f tree = let rec mapInner tree cont = match tree with | Leaf x -> //このケースに入る Leaf(f x) |> cont | Node (left, right) -> mapInner left (fun leftResult -> mapInner right (fun rightResult -> Node(leftResult, rightResult) |> cont)) mapInner tree id 29

Slide 30

Slide 30 text

contを呼び出した結果 Leaf 4 |> (fun rightResult -> Node(Leaf 2, rightResult) |> id) Node(Leaf 2, Leaf 4) |> id Node(Leaf 2, Leaf 4) 30

Slide 31

Slide 31 text

改めて全体像 let map f tree = let rec mapInner tree cont = match tree with | Leaf x -> Leaf(f x) |> cont | Node (left, right) -> mapInner left (fun leftResult -> mapInner right (fun rightResult -> Node(leftResult, rightResult) |> cont)) mapInner tree id 31

Slide 32

Slide 32 text

でも継続渡しスタイルが最適化されないこともある… 今回例に用いているF#では継続渡しスタイル(CPS)が末尾呼び出し最適化されるが、 末尾呼び出し最適化されない言語(JVM系の言語など)もあるのでTrampolineなど別の手 法をとる必要がある。 またスタック領域に関しては領有されることはないが、継続の関数のネストによりヒ ープ領域を領有するので、そっちが問題になるパターンも… 32

Slide 33

Slide 33 text

再帰的な構造 今まで見てきた再帰は構造的再帰と呼ばれる。 構造的再帰は、データ構造の定義に従って再帰呼び出しを行う再帰のパターンで、 データ構造が再帰的に定義されている場合、それに対応する関数も自然に再帰的にな る。 再帰的データ構造の例 リスト 自然数 木 33

Slide 34

Slide 34 text

一般的な再帰 一般的な再帰は、データ構造の形に直接対応していない再帰 34

Slide 35

Slide 35 text

クイックソートを例に見てみる クイックソートは与えられたリストを整列するアルゴリズム 分割統治法を用いてリストを整列する ※ 分割統治法:問題を部分問題に分割し、各々を独立に解いて、得られた解から全体の解を計算 する手法 35

Slide 36

Slide 36 text

36

Slide 37

Slide 37 text

まず再帰のベースケースを考える クイックソートにおいて空リストなら、自明に [] であることがわかる let rec quickSort lst = match lst with | [] -> [] | _ -> failwith "TODO" 37

Slide 38

Slide 38 text

次に再帰ステップを考える 構造に従った再帰の場合、部分問題を常に first :: rest のrestということしか考え る必要はなかったが、一般の再帰の場合は部分問題を生成する必要がある。 let rec quickSort lst = match lst with | [] -> [] | first :: rest -> failwith "TODO" 38

Slide 39

Slide 39 text

基準の要素をfirst、それより小さい要素のリストと大きい要素のリストを作り、更にそ れらに対して再帰呼出しをする let rec quickSort lst = match lst with | [] -> [] | first :: rest -> let smaller, greater = List.partition ((>=) first) let sourtedSmaller = quickSort smaller let sourtedGreater = quickSort greater failwith "TODO" 39

Slide 40

Slide 40 text

それぞれの結果を結合させる let rec quickSort lst = match lst with | [] -> [] | first :: rest -> let smaller, greater = List.partition ((>=) first) rest quickSort smaller @ [first] @ quickSort greater 40

Slide 41

Slide 41 text

クイックソートを実装できた let rec quickSort lst = match lst with | [] -> [] | first :: rest -> let smaller, greater = List.partition ((>=) first) rest quickSort smaller @ [first] @ quickSort greater 41

Slide 42

Slide 42 text

継続渡しスタイル版 let quickSort lst = let rec inner lst cont = match lst with | [] -> cont [] | first :: rest -> let smaller, greater = List.partition ((>=) first) rest inner smaller (fun s -> inner greater (fun g -> cont (s @ [first] @ g))) inner lst id 42

Slide 43

Slide 43 text

まとめ 関数プログラミングではループ構文ではなく、再帰を利用する 再帰の最適化には言語にもよるが末尾呼び出し最適化が利用される データ構造に直接対応していない再帰も存在する 43

Slide 44

Slide 44 text

参考 https://fsharpforfunandprofit.com/posts/computation-expressions-conts/ https://practical-scheme.net/docs/cont-j.html 浅井健一(2007) 『プログラミングの基礎』 (Computer Science Library 3) 、サイ エンス社. Stuart Halloway, Aaron Bedra(2013) 『プログラミングClojure 第2版』 、オーム社、 (原著:Stuart Halloway and Aaron Bedra, "Programming Clojure, 2nd edition", Pragmatic Bookshelf 2012、翻訳:川合史朗). Michał Płachta(2023) 『なっとく!関数型プログラミング』 、翔泳社、 (原著: Michał Płachta, "Grokking Functional Programming", Manning Publications 2022、翻 訳/監修:株式会社クイープ). 44