2017年度「プログラミング言語」配布資料 (11)
五十嵐 淳 (京都大学大学院情報学研究科 通信情報システム専攻)
2017年12月15日
- 高階関数
- 高階関数 in OCaml (ocaml/hofun)
- オブジェクトを使った高階関数プログラミング in Java (java/hofun)
- 関数ポインタを使ったプログラミング in C (C/hofun)
[ 前の資料 | 講義ホームページ・トップ | 次の資料 ]
高階関数
\(\sum_{i=n}^m x_i\) という記法は(実)数列 \(\{x_i\}\) の第\(n\)項から第\(m\)項までの和を表す.この \(\sum\) をひとつの演算子だと捉えようとすると,これは自然数 \(n\), \(m\) と数列 \(\{x_i\}\) に作用する演算子,もしくは,関数と考えることができる.数列は自然数 \(i\) から実数への関数であるので,\(\sum\) は関数を引数とする関数である.プログラミングにおいて,このような「関数を引数とする関数」を実現したり,関数を変数に格納したり,関数が(整数や文字列などの通常の)データと同様に1扱えることを指して「関数が 第一級の値(first-class value) である」といったりする.
また,概念的には「『関数を引数とする関数』を引数とする関数」などが考えられるが,この「複雑さ」に特に制限が設けられていない時,プログラミング言語が 高階関数(higher-order function) をサポートしている,高階プログラミングができる,などという.
この「階」とは,関数の「複雑さ」を示す度合いで,
- 一階の関数 (first-order functions) … 整数,文字列などの基本的データ,基本的データのみで構成されるレコードなどを引数とする関数
- 二階の関数 (second-order functions) … 一階の関数を引数とする関数(\(\sum\)など)
- \(n\)階の関数 (\(n\)-th-order functions) … \(n-1\)階の関数を引数とする関数
と定義される.
関数が第一級の値であることは「データの操作」の受け渡しが許されている,ということとも考えられる.これはオブジェクトのような「データとその操作をいっしょにしたもの」が受け渡しできるオブジェクト指向言語では,ある意味当たり前にできていることである.そのため,明示的に関数の概念が現れないオブジェクト指向言語でも,実質的には高階関数が利用できると考えられる.
高階関数 in OCaml (ocaml/hofun)
OCaml も含め,一般に関数型言語は高階関数が書きやすく設計されている.
関数を引数とする関数
最初の例である \(\sum\) を考えてみる.(ただし,煩雑にならないよう,最初の項は第0項で固定とする.)
関数 sigma は,整数上の関数 f と,自然数 n を引数として,f(n) + f(n-1) + ... + f(0) を計算する.以下が sigma の定義である.
1 2 3 | |
f は,n と同様に,特に特別な記法もなくふつうにパラメータとして書けばよい.関数本体では,f 0 のように,他の let で定義された関数と同様に使われている.sigma の型は例によって型推論機能が答えてくれる.
val sigma : (int -> int) * int -> int = <fun>
第1引数が int -> int 型の値,すなわち,整数から整数への関数であることが示されている.-> は * よりも結合が弱いので () が必要となる.(この括弧がなかった時の読み方・意味は後でふれることにする.)
この関数を呼ぶには,引数に定義済の関数の名前を与えてやればよい.
1 2 3 4 5 | |
匿名関数
関数が第一級の値であるような言語のうち多くでは,名前をつけずに関数を表す記法が用意されている.このような名前のない関数を 匿名関数(anonymous function) と呼ぶ.OCaml では匿名関数は fun〈パラメータ〉-> 〈式〉 という形で表す.以下が,上の a b の定義を匿名関数を使って書いたものである.
1 2 3 | |
fun n -> n*n で「n を受け取って n * n を返す関数」を意味する.
fun はできるだけ伸びるので,しばしば括弧が必要となる.上の例で括弧を省略すると -> の後が n * n , 20 ということになって,n * n と 20 の組を返す関数になってしまう.(組のことはほとんど説明していない.あしからず.)
この記法を使うと,cube を以下のように定義することもできる.
let cube = fun n -> n * n * n実は,let f x = ... という記法は let f = fun x -> ... の略記なのである.
カリー化による複数引数関数の表現
これまで複数の引数をとる関数は let f(x,y) = ... のように,複数の引数を組にしてとるように書いてきた.実は,高階関数,特に,関数を返す関数を使うと,複数引数関数を,1引数関数の組み合わせで表現することができる.このテクニックを カリー化(Currying) (カリーは人名),またそうして表現された関数を カリー化関数(Curried function) と呼ぶ.
例を見てみよう,以下の greeting は,性別 gen と名前を表す文字列 name を受け取って,挨拶文(敬称が性別で違う)を返す簡単な関数である.
1 2 3 4 5 6 7 8 9 | |
カリー化のアイデアは2引数関数を,「最初の引数を受け取って,『第2の引数を受け取って結果を返す関数』を返す関数」として表現する,ということである.一般に,\(n\)引数関数なら「最初の引数を受け取って『残りの引数を受け取って結果を返す \(n-1\)引数関数』を返す関数」と表現される.以下はカリー化された greeting である.
1 2 3 4 | |
この関数の型は以下のようになる:
val : curried_greeting : gender -> string -> string = <fun>この型は gender -> (string -> string) と同じ(->は右結合)で,gender を受け取ると,string -> string を返す関数であることが型から読み取れる.
カリー化された関数を呼ぶ時は,まず,性別を与えると,(名前を受け取って挨拶分を返す)関数が返ってくるので,それを呼ぶ,という手順になる.
let greeting_for_men = curried_greeting Male
let greeting_for_women = curried_greeting Female
let g1 = greeting_for_men "Igarashi"
let g2 = greeting_for_women "Koike"
curried_greeting x は,関数 fun name -> ... を返すわけだが,この時 gen が,渡した x に固定されたような関数が返ってきている,ということに注目してもらいたい.だからこそ,次の引数である文字列が渡された時に,最初の引数によって異なる挙動を示している.
一般に,(匿名)関数本体では,パラメータ以外の変数が現れるわけだが,その値は,fun が評価された時(fun に実行が到達した時)に固定される.関数が呼び出された時には,その以前に固定された値を使って,本体の計算が行われる.それゆえ,一般に関数を(コンパイラなどの言語処理系のレベルで)表現するためには,関数のコード(fun 以下の情報)に加え,パラメータ以外の(固定された)変数の値を記録しておく必要がある.それらをまとめたものを 関数閉包(function closure) と呼ぶ.これは言語処理系レベルで第一級関数を実現するための技法である.
curried_greeting や g1, g2 の別定義
cube の別定義でみたように,定義のための = の直後の fun x -> は = の左側に移せるので,curried_greeting も fun キーワードを使わずに以下のように定義することができる.
1 2 3 4 | |
また,呼び出す際にも,最初の引数を渡したものに,いちいち名前をつけずに,以下のように呼び出すこともできる.
let g1 = (curried_greeting Male) "Igarashi"
let g2 = curried_greeting Female "Koike"しかも,g2 のように左側の括弧は省略できる(関数適用は左結合)ので,こう書くと,() を使わずに2引数関数が書けているように見えるだろう.
カリー化された複数引数関数は,全ての引数を一度に与えて呼び出すことが多いが,greeting_for_men のように,最初の方の一部の引数だけを与えて,引数の一部の値を固定した関数を作ることもしばしばある.このような一部の引数だけを渡すことを 部分適用(partial application) という.後の方の引数を固定するのは少し面倒なので,カリー化を行う際には,固定されやすい引数を最初にもってくることが多い.
2分木のための高階関数
いつもの(整数を格納した)2分木を考える.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
データ構造に典型的ないくつかの高階関数を見ていこう.
map
map は,データ構造に格納された各データ(この2分木の場合は branch の整数)に関数(パラメータ) f を適用して得られるような別の2分木を返す操作である.
1 2 3 4 5 6 7 8 9 10 | |
fold
fold は畳み込みとも呼ばれる.データ構造の各コンストラクタ \(C_i\) (\(n_i\) 引数)を \(n_i\) 関数 \(f_i\) に置き換えて「解釈」し,値を得る関数である.(0引数コンストラクタには定数を割り当てる.)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
オブジェクトを使った高階関数プログラミング in Java (java/hofun)
オブジェクト≒関数のレコード
メソッドを関数と考えると,オブジェクトは複数のメソッドに名前をつけたレコードと考えることができる.特に,メソッドをひとつだけ持つオブジェクトを考えれば,第一級関数を表現することができる.
まず,関数の型毎に(メソッドがひとつだけの)インターフェースを用意する.インターフェースの名前は引数・返値の型を表す名前にしておく.メソッドの名前はなんでもよいが,ここでは Java のライブラリにある Function という(ジェネリック)インターフェースにあわせ apply とする.(Function などのライブラリのインターフェースについては,後述する.)
1 2 3 | |
そして,そのこのインターフェースを implements したクラスを作ることで関数を表現する.整数を2倍する関数であれば,以下のようになる.インスタンス変数は特に必要ない.
1 2 3 4 5 6 7 8 9 | |
例えば,この「関数」を作って,呼び出すには以下のようにすることになる.
1 2 | |
さて,この手法を使って,2分木の map や fold をプログラムしてみよう.
map
今までの話を総合すれば特筆すべきことはない.まず,Tree インターフェースには map の宣言を追加する.引数が上で定義した整数上の関数を表すインターフェースになる.
1 2 3 4 | |
そして,Leaf と Branch の各クラスに,処理を追加する.Branch では関数オブジェクト f を呼び出して新しい木に格納する整数を計算する.
1 2 3 4 | |
1 2 3 4 5 6 7 | |
以下が,この map を使った例となる.
1 2 3 4 5 6 7 8 9 10 11 12 | |
fold その1
Br を置き換える3引数関数のためにインターフェースを新しく用意する必要があるが,その他は特筆すべきところはない.インターフェース ThreeIntsToInt が整数3つから整数への関数を表すインターフェース,Sum3 が与えられた3整数の和を返す関数オブジェクトのクラス Sum3 である.
1 2 3 4 | |
1 2 3 | |
1 2 3 4 5 6 7 8 9 | |
1 2 3 4 | |
1 2 3 4 5 6 | |
System.out.println(t6.fold(0, new Sum3()));
// displays the sum of 10, 25, ..., and 30.fold その2
その1の実装では,leaf と branch のために別の引数を用意したが,これらをひとつのオブジェクトにまとめることも可能である.実装その2として,
- 葉の時に呼び出すメソッド
- 枝の時に呼び出すメソッド
を持つオブジェクトを受け取る形で書いてみよう.このオブジェクトは,木の場合分け処理を表現していることから,インターフェース名をCaseForTreeとしている.
1 2 3 4 5 | |
1 2 3 4 | |
1 2 3 4 5 6 7 8 9 10 | |
1 2 3 4 | |
1 2 3 4 5 6 | |
1 2 | |
CaseForTree に並べたメソッドの名前を caseLeaf,caseBranch としたところから気付くかもしれないが,この fold の実装は,ビジターパターンによく似ている.だいたい,fold メソッドが accept に対応していると考えられるが,よく見てみると以下のような違いがある.
- ビジターパターンでは,
caseBranchメソッドは,訪れたBranchオブジェクトのインスタンス変数の値を引数として,どの部分木に再帰呼出しを行うかもcaseBranchメソッド内で決定している - この
foldでは,再帰呼出しはfoldメソッドの中で先に行ってしまい,その再帰呼出しの結果をcaseBranchメソッドに渡している.foldの場合は再帰呼出しの方法が固定されているので,このような定義になる(のが自然である).
関数インターフェースとラムダ式
さて,ここまで関数を表現するためにいちいちクラスを書いてきたが,これらの実装の問題点は,
- 関数の引数型・返値型毎にインターフェースを用意する
- 関数毎に別のクラスを用意する
のがかなり煩雑である,という点である.ひとつめの問題は,ジェネリクスを用いることである程度解決する.例えば,ライブラリjava.util.functionにある2Function<T,R>というジェネリックインターフェースにはR apply(T x);というメソッドが宣言されている.よって,上のIntToIntのようなインターフェースはFunction<Integer, Integer>と書けばよい.(intをIntegerに変える必要はある.) 3 また,ふたつめの問題は,現在の Java では「ラムダ式」で解決することができる.「ラムダ式」は OCaml のfun記法とほぼ同じアイデアで匿名関数を表すための記法である.4
Java でのラムダ式の記法は以下の通りである.
| 記法 | 備考 |
|---|---|
x -> 〈式〉 |
1引数で〈式〉の値を返す |
(x1,...,xn) -> 〈式〉 |
\(n\)引数で〈式〉の値を返す |
x -> { ... } |
1引数で,... の文(の列)を実行する.return された値が返る |
(x1,...,xn) -> { ... } |
\(n\)引数で,... の文(の列)を実行する.return された値が返る |
ラムダ式はメソッドをひとつしか持たないインターフェースが期待される文脈で使うことができる.例えば,上の Sum3 は,クラスを書かずとも,以下のようにして作ることができる.
ThreeIntsToInt f = (n, m, p) -> n + m + p;ここで「文脈」といっているのは,ThreeIntsToInt 型の f に代入しようとしている,というプログラム上の文脈のことである.f の型から,この関数は apply メソッドの実装であると解釈してもらえるわけである.また,(ふつうは)文脈側にあるインターフェースから型が決まるので,パラメータの型宣言は特にしなくても型推論が行われる.(ちなみに,型をつけるならば (int n, int m, int p) -> n + m + p のように全ての引数につける必要がある.(n, int m, p) -> ... のようなものはエラーになる.5)
上の map や fold(その1) を使う例を書き直すと以下のようになる.
1 2 3 4 5 6 | |
Java のラムダ式は,結局のところオブジェクトである.よって,ラムダ式を呼び出す特殊な構文はなく,呼び出すためには必ずメソッド呼び出しを経由する必要がある.つまり,クラスを定義せずにオブジェクトを作る特殊な記法,程度に理解しておくべきである.コンパイルした結果を見ると,実はラムダ式に対応するクラスが自動で生成されている.また,(本講義ではこれ以上深くはふれないが)ラムダ式の使用に関するいくつかの規則も,ラムダ式がクラス定義+newの略記であることを知っておくて理解しやすいだろう.
2017年版ではC言語を後回しにしているので,ひとまず,以下は説明しない.
関数ポインタを使ったプログラミング in C (C/hofun)
C言語には OCaml のような一般的な高階関数を扱う機能はない.そもそも(GCCの独自拡張を除くと)関数の中で関数を定義できないので,カリー化関数を表現したかったら,自分で関数閉包を作ったり,関数閉包を呼び出す仕組みを作ることになる.また,匿名関数のための仕組みはない.それでも,上で紹介したような高階関数を使ったプログラミングが「ある程度」可能であることを紹介する.
関数ポインタは,概念としては関数を指すためのポインタで,* を使って関数を参照して呼び出すことができる.まず,\(\sum\) の例を見てみよう.
int sigma(int (*f)(int), int n) {
if (n < 1) {
return (*f)(0);
} else {
return (*f)(n) + sigma(f, n - 1);
}
}
int square(int n) {
return n * n;
}
int cube(int n) {
return n * n * n;
}
int main(void) {
printf("1^2 + ... + 20^2 = %d\n", sigma(square, 20));
printf("1^3 + ... + 20^3 = %d\n", sigma(cube, 20));
return 0;
}C言語の関数ポインタまわりでややこしいのは変数の型宣言の構文である.後置された (int) が整数を1つ引数に取る関数であること,f の前の * が f がポインタであることを示している.
main 関数にあるように,関数へのポインタを他の関数に渡す時には関数名だけを書けば,&をつけなくても暗黙のうちにポインタとして扱われる.また,呼び出しの際にはポインタの参照 *f をしているが,これも括弧が必要である.この括弧がないと,「関数 f を読んだ結果(f(n))のポインタが指す先を * 参照する」という意味になってしまう.(C言語では関数呼び出しも後置演算子扱いで,後置演算子は常に前置演算子よりも優先される.)
型宣言の読み方
C言語の型宣言は,識別子の前後にいろいろくっついて型を表すこともあり,読み方がややこしい.
- 識別子から読み始める.
fなら "fis ..." と読み始める - 各記号の読み方は以下の表の通り.
- (括弧がない限り)後置の
()(や[]) を優先して読む - 最後に左の型を読むと
fが何かがわかる.
| 記法 | 読み方 | 備考 |
|---|---|---|
(...) (後置) |
a function returning | 括弧内は引数の型をカンマで並べる |
[...] (後置) |
an array of | ... には配列の要素数を書いてもよい |
* (前置) |
a pointer to |
例えば int (*f)(int) は f is a pointer to a function (taking an int) returning int と読むことができる.*f のまわりの括弧がないと,f is a function returning a pointer to int となって,違う意味になってしまう.
また,関数は「関数へのポインタ」を返すことはできるが「関数そのもの」を返すことはできないため,OCaml での f: T1 -> T2 -> T3 のような(T1 型の引数を受け取ったら,「T2型の引数を受け取りT3型の値を返す関数」を返す)関数を定義したい場合は,「T1 型の引数(仮にxとしよう)を受け取ったら『T2 を受け取りT3を返す関数』へのポインタを返す関数」として定義す.この場合,f の定義は T3 (*f(T1 x))(T2) { ...} のように書く.(上の読み方との対応を取ってみよ.)
表記ゆれいろいろ
関数の受け渡しは必ずポインタでされることから,いくつかそれを利用した表記揺れが存在する.
- 実は,
fの宣言につけた*は省略して,
int sigma(int f(int), int n) { ... }と書いてもよい.が,これは関数パラメータに限られ,{} 内の局所変数の場合は省略できないので,この講義では常に * をつける.
関数ポインタを使った呼び出し
(*f)(n)も,*を省略してf(n)と書いてよい.fがポインタであることを意識・明示するために,この講義では常に*をつける.関数のポインタを取得する際に
&をつけて (sigma(&cube,20)のように書いて)もよい.上の点とは一貫していないようにも思えるが,この講義では&はつけない.配列(これも関数と同様受け渡しできるのはポインタだけである)の場合,配列名が先頭要素へのポインタに自動変換されるのと揃えている.
2分木のmapとfold
記法さえ飲み込めば map や fold を書くのは難しくない.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | |
[ 前の資料 | 講義ホームページ・トップ | 次の資料 ]
「同様に」の意味は厳密ではないが,通常,変数に格納したり,他の関数・手続・メソッドに受け渡しできる,実行時に生成できたりすることを指す.↩
使うには `import java.util.function.*;' を各ファイル冒頭に置くこと↩
他に2引数関数のための
BiFunction<T,U,R>というインターフェース(TとUが引数の型を表す)もあるが,3引数以上の場合は用意されていないようだ.↩「ラムダ」というのはギリシャ文字の \(\lambda\) のことである.ラムダ計算(\(\lambda\)-calculus) という計算のモデルの理論で,\(x\) を引数とし(\(x\) を含む)〈式〉を計算して返す関数を \(\lambda x.〈式〉\) という形式で表すことに由来している.例えば,「1を足す関数」は \(\lambda x. x + 1\) と書く.プログラミング言語によっては
lambdaが匿名関数のキーワードになっている場合もある.(Lisp など)↩Java言語仕様Java SE8版 15.27節↩
Copyright 五十嵐 淳, 2016, 2017