見出し画像

Retro-gaming and so on

map-take

アッチコッチで書いてるんだけど、もう一回ここにまとめておこう。
Pythonを勉強してて、「Python初心者を卒業した」と自信を持って言えるのは、次の3つの条件をクリアした時、だ。

  1. Pythonの機能としてはリスト内包表記を使いこなせる事。
  2. Pythonのデータ型としては辞書型を使いこなせる事。
  3. Pythonの関数としてはfunctools.reduceを使いこなせる事。
この3点、だ。この3点さえ押さえられれば「Python初心者は卒業した」と自信を持っていい。
もちろん、この3つは互いに独立してるわけじゃなく、お互いに知識を共有しあったり、他の関数や機能の知識も必要となってくるだろう(条件式の理解は当然、とする!)。
例えば、Pythonのデータ型では辞書型が最重要だ、とは言えるが、一方、リスト内包表記を扱う以上、データ型としてのリストの知識は必須となる。
そしてここには遅延評価絡みの機能は書いてない。一つはそれは中級者以降の為、の機能であるし、もう1つはこの3点(というより1.と3.の2点)を押さえてないと、これら機能の「遅延評価版」を理解できないから、だ。
言っちゃえばリスト内包表記の遅延評価版がジェネレータ式で、functools.reduceの遅延評価版がitertools.accumulateだ。
逆に言えば、リスト内包表記とfunctools.reduceが分かれば遅延評価を理解する為の敷居が下がるんだ。

特にfunctools.reduceを最重要関数として挙げた。これはPythonの組み込み関数の中で、もっとも汎化性能に優れた関数だ。そう、色んな機能を抽象化した最たるモノだ。
まぁ、このブログでRacket/Lispの記事を読んでる人には既にお馴染みだろうが、改めて例示をもって説明する。
例えばPythonにはsumという関数がある。リスト(に実は限らないんだけど)に入ってる数値を合計する関数だ。

>>> sum([1, 2, 3, 4, 5])
15

これを自作せよ、という問題があったとする。C言語脳的にはforで回して・・・とか思うかもしんないけど、んな事はやらん。sumは基本的にfunctools.reduceを使えば以下のように簡単に書けてしまうんだ。

def my_sum(lst):
 from functools import reduce
 from operator import add
 return reduce(add, lst, 0)

>>> my_sum([1, 2, 3, 4, 5])
15

かなりの確率でfunctools.reduceを利用すればPython組み込みの関数も書けるし、簡単に貴方の書きたい関数が書ける。
つまり、かなりの確率であらゆる関数の基礎であり、汎化性能がある。つまり抽象化の極み、ってのがこのfunctools.reduceなんだ。
よってこれを押さえておかないとならないし、自在に扱えるようにならないといけない。

この3点、ってのは別に僕の趣味で言ってるわけじゃない。マジで基礎中の基礎、って言って良い3点なんだ。高級プログラミング言語の主目的が抽象化である以上、抽象化の極みであるこの3点を押さえないといけない
よってこれらを把握しない以上、貴方は本当の意味ではPython等の高級プログラミング言語を扱えていない、って事になる。
だから僕の意見ちゃうっちゅうねん(笑)。それこそLispから始まる関数型言語の結論、なんだ。そしてその歴史はC言語より長い
貴方は本当に、これを読むべきだ。 => なぜ関数プログラミングは重要か

いきなり関数プログラミング、という結論にLispは到達したんだけど、そうじゃない言語は「じわじわと」ゆっくりと、物事を抽象化してきた。初めはジャンプしかなかった。抽象化により「条件分岐」と「反復」が誕生した。しかし、その先の「抽象化」もあるんだ。特に反復に対して、「その先」の抽象化をイテレータと呼ぶ。
Lispではmapreduceが最高峰のイテレータだ。そして「プログラミング初心者」でさえ、まずはmapreduceの使い方を覚えるべき、って程だ。極端にいうと、反復構文なんざ覚える必要がない。mapreduceは安全で、物事の仕組みをより簡単にする。よって最初に慣れるべきはmapreduceなんだ。
Pythonは(というより正確にはHaskellだが)Lispのmapをもっと抽象化した。もっと一般的に汎化した「構文」にした。それがリスト内包表記、だ。
リスト内包表記を使いまくれ、functools.reduceを使いまくれ。それがPythonの根本思想で、逆らう事が出来ない「Python設計からのメッセージ」なんだよ。

もういい加減心苦しくなってはいるんだけど、あまりに典型的な、ある意味どーしようもないテンプレな事を書いてるんで、もう一回だけ敢えて取り上げよう。


「変数定義・型・分岐・反復・関数」なんぞは別にC言語の発明でもなんでもないし、これらを持って「主要プログラミング言語の親だ」なんつー主張は片腹痛い。
そしてそんなトコに基礎はない。っつーか欠けてるものもある。って事は網羅的ではない、って事だ。
まずは、反復を学ぶ前に再帰を学ぶべきだ。これも何度も言ってるが、再帰は別に難しくない。少なくとも、高校数学で漸化式として概念を学んでる。
そして、高校を最低でも卒業した日本人にとっては再帰は単なる一般教養なんだよ。
再帰はユニバーサルだ。再帰は構文じゃない。考え方なんだ。これこそ1つの言語で学べばどこでも使えるテクニックで、それを含めないモノで基礎を主張する、なんつーのはちゃんちゃらおかしい。
そして今回ここで取り上げてるのは反復をより抽象化したイテレータで、これも基礎だ。C言語でちまちまとforを回してる間に簡単に物事を成し遂げてしまう。ここでも抽象化が成されてる。より高度な抽象化だ。
抽象化はモダンな高級プログラミング言語の肝だ。結局、C言語のような抽象度が低い言語を学んでも抽象度が高い「高級言語」の理解なんざ出来ない。
mapreduceもねぇような言語に「基礎教育」の出番なんざねぇんだ。

繰り返そう。昔はジャンプしかなかった。ジャンプを抽象化して(一つは)いわゆる(forwhileみたいな)反復構文になった。今度は反復構文も「より抽象化して」mapreduceにした。そして、mapのその先にPythonのリスト内包表記がある。
今回はこの内、mapを取り上げる。正確にいうと、mapの、リスト内包表記とは別方向への抽象化が可能か否か、っつー話だ。

例えば次のような問題を考える。

リスト'(1 2 3 4)に対して要素の数それぞれに1を加えたリストを返せ。

PythonなんかでC言語脳的に解くと次のようなコードになるだろう。

lst = [1, 2, 3, 4]
answer = []
for i in lst:
 answer.append(i + 1)

あるいはRacketなんかだとこう書く。

(let ((lst '(1 2 3 4))
  (answer '()))
 (for ((i lst))
  (set! answer (append answer `(,(add1 i)))))
 answer)

これらコードには色々と問題がある。例えばanswerなんかの「新しい空リスト」を用意してそれを「破壊的変更しながら」値を追加していく。
そして何だかんだ言って、「リストから一々値を取り出して」「それを加工する」と言う指示が、抽象化されてない考え方なんだ。

「リストから一々値を取り出す?うへぇ!」

ってのが正しい高級言語使いの感想だ。
「リストから一々値を取り出して加工する」んじゃなくって「リストをまとめて扱う」のが抽象化、だ。mapがある言語なら次のように書く。

(map add1 '(1 2 3 4))

Pythonならリスト内包表記を使う局面なんだけど、まぁそれは今回は省こう。
いずれにせよリストそのものをまとめて一個として扱う、と言うように考えるのが抽象化なんだ。決してバラバラにしない。要素を一個づつ取り出さない。
もちろん、mapの実装自体は実は「リストの先頭から一個づつ要素を取り出して」処理している。しかし、それを隠蔽してるわけだ。実装が内情を隠蔽してるお陰でプログラムを書く側はさも「リストと言う一個のまとまりを」操作する、って感覚だけでプログラムを組む事が出来る。
内情を考えるとバカバカしい、って思うかもしんない。しかしそれが大きいんだ。思考が一段上の部分に登れる。
信じられないって?しかし、この「まとめて一個として扱う」と誤解させるトリックは実はC言語にも存在する。
例えば以下のようなプログラムを考えてみよう(※1)。



8ビット幅の符号無し整数であるuint8_t型の配列を宣言している。
この配列の「中身」を出力したい場合、配列の1要素1要素をルーピングしながら出力関数printfに送り込んでいるわけだな。
C言語には配列がない。従ってまとめて扱えない。
だから嫌でも「メモリに含まれてる要素一個一個を」チェックしながらそれを出力関数に送り込むしかないわけだ。
そして「C言語には文字がない」。数値しかないんだ。
従って、printfの書式指定子をイタズラして次のように変更すれば、こういうモノが出力される。



"%" PRIu8 " "と言う記述を"%c"に改造するとHello, World!と出力される。何故なら%cと言う書式指定子は受け取った数値を「文字」として解釈するからだ。
つまり、符号なし整数、uint8_tと言う型が定義する数値は、ASCII文字として解釈が可能だ、と言う事による。
しかし、依然としてルーピングしてるわけだよ。「配列をまとめて扱えない」からこうなってんだけど、一方、これはフツーだとこうプログラムする事が可能だ。



ルーピングが消えてしまった。つまり、uint8_t型で宣言されたはずの配列arrayは「文字列として」出力が可能で、要はこの「文字列」ってのが「アスキーコード前提での」抽象化だ、って事だ。結果として「文字列は配列の筈なのにまとめて扱う事が」出来ている。だからこのケースに限定してはルーピングが必要ない、ってわけだ。
要点を整理しよう。

  1. アスキーコードは符号なし8bitの数値と「文字」を対応させている。
  2. C言語には配列がない。
  3. C言語には数値しかない。
  4. しかしあるメモリをあるアドレスから辿っていって、0を発見した場合、そこまでを「文字列」というカタチで解釈出来る。
  5. 文字列、と解釈する前提なら数値0はアスキーコード表に従ってヌル文字と解釈出来る。
つまり、C言語は「数値の配列」はまとめて扱えないんだけど、「文字の配列(ヌル文字付き)」だけはまとめて扱えるように(不十分ではあっても)printfの書式指定子%sによって抽象化してる、って事だ。背景としては文字列を印字する為だけにプログラマ側がルーピングを書くのはいくらなんでもアレだろ、ってのがあるからだ。
これにより、C言語でも文字列だけはあまり意識せずに出力は出来るようになってんだ。2番目のコードように「文字をルーピングで出力」を要求されてたとしたら・・・考えたくもねぇだろ(笑)?
繰り返すけど、内情がどうあれ、「何かをまとめて扱える」ように抽象化するのは、こういうように便利なんだよ。「文字をルーピングで表示する」よか「文字列として一括して出力」出来る方がありがたいってわけだ。
抽象化、ってのはそういう事なんだ。

mapは内情がどうあれ、与えられたリストを一括で処理出来るように「思わせている」。実際は与えられたリストの要素を先頭から一個一個引っこ抜いてきて関数を適用させている。しかし、その「内情」を意識する必要はまったくない。
しかし、先にも書いたけど、もうちょっと抽象化してみよう。この「一個一個引っこ抜く」って部分をもっと汎化出来ないか。
実はプログラミングをしてると、リストがあって、その先頭から「何個が取ってきてはその全部に関数を適用したい」って思う場面がたまにあるんだ。そういう時、「一個に限って適用する」って言うmapの仕組みがちと歯がゆかったりする事がある。リスト操作なら操作対象自体がリストでも良くないか?
以前見た例をもう一度考えてみよう。



これはC言語入門レベルの問題だったからC言語を使って「出力にこだわって」解いた。
一方、同じ問題をLispやPythonで書くのなら、明らかに「ラテン方陣」と言うデータを生成して「返す」べきだ。C言語入門レベルとは違って、LispやPythonでプログラミングをする際には「出力」にこだわる必要はない。必ず生成したデータ型をreturnして、いつでもそのプログラムを「再利用する」事を考えるべきだ。
これは毎回言っている。
ところが、確かにこの「データ生成」は面倒くさいように思える。
実は途中までの発想、つまり、'(1 2 3 1 2 3)等のリストを生成するまで、は同じなんだ。そして

'(|1 2 3| 1 2 3)
'(1 |2 3 1| 2 3)
'(1 2 |3 1 2| 3)

と言うカタチで「リストを生成」していけばいい。
ところが、こういうパターンは、既視感を覚えるけど「抽象化が上手く行かない」パターンなんだよ。
要は手が次のように書きたがるんだけど、「この路線じゃダメだ」って事になるんだ。

(map 何とか '(1 2 3 1 2 3))

何故か?mapは「リストの要素を先頭から一個一個」しか取れないからだ。
ここで考える。「リストの先頭から何個かまとめて取りつつ処理出来ないか?」と。「そう出来れば嬉しいな」と言うのは回数は少ないにしても確実にあるんだ。

と言うわけで、ユーティリティmap-takeを紹介する。
繰り返すがmapは与えられたリストの先頭から「一個一個」処理していく。しかし、map-takeはリストの先頭から「何個かまとめた」リストを取って、それに対して処理していく。上のデータ生成の「方法論」そのままの高階関数だ。
まずはmapの仕組みを復習しよう。mapは教科書的、かつ単純には次のような再帰関数だ。

(define (map f lis)
 (if (null? lis)
   lis
   (cons (f (car lis)) (map f (cdr lis)))))

発想の立脚点としてはこれを改造する事を考える。最終的には次のような「枠組み」が必要になる、ってのがわかるだろう。

(define (map-take f pos lis)
 (if ...
  lis
  (cons (f (
take lis pos)) (map-take f (cdr lis)))))

そう、carを使う代わりにtakeを用いる。takeは与えられたリストの先頭からpos個だけの要素を引き抜いたリストを返す。

> (take '(1 2 3 4 5) 2)
'(1 2)
>

これにより、常にcdrダウンされたlisの先頭からpos個だけの要素を引き抜ける。
では終了条件をどうするのか。ここを「考える」のがメンドイんだけど、いい方法がある。Racketのtakeposが与えられたリストの長さを超えてた場合、例外を送出するんだ。

> (take '(1 2 3 4 5) 6)
. . take: contract violation
expected: a list with at least 6 elements
given: '(1 2 3 4 5)
>

以前書いたが、モダンなプログラミングではエラーさえも利用してプログラミングする。つまり、cdrダウンしたlisの長さがposより短くなった時にエラーが起きる事を畏れる必要はない。むしろ、その「エラー送信」を利用して終了条件、とする。

(define (map-take f pos lis)
 (
with-handlers ((exn:fail:contract?
         (lambda (exn) '())))
  (cons (f (take lis pos)) (map-take f pos (cdr lis)))))

takeは与えられたリストの長さよりposの値が大きかった場合exn:fail:contractと言う例外を送出する。コイツを例外処理機構、with-handlersで掴まえた時に空リストを返すようにする。
繰り返すがクソマジメにわざわざ「終了条件」を考える必要はない。モダンなプログラミングではエラーを利用して「終了条件」にしていいんだ。

> (map-take identity 2 '(1 2 3 4 5))
'((1 2) (2 3) (3 4) (4 5))
>

これで一応、仕様に関しては満足出来た。
ただし、上のmap-takeは単なる再帰なんでまだまだ効率が悪い。
そこで名前付きletを利用した末尾再帰に書き換える。

(define (map-take f pos lis)
 (let loop ((lis lis) (acc '()))
  (with-handlers ((exn:fail:contract?
          (lambda (exn) (reverse acc))))
   (loop (cdr lis) (cons (f (take lis pos)) acc)))))

もう1つ問題がある。mapは可変長引数としてリストを複数取れる。
しかし、上のmap-takeの定義だとリストを一つしか取れない。
よって「可変長引数」としてリストを引数として取るように仮引数分をこう書き換える。

(define (map-take f pos lis1 . lists)
 ...)

最低でもリストは1個取らなければならないが、listsは残りのリストを一つのリストにまとめる。必須のリスト1個しかない場合はlistsは空リストになる。
と言う事は、listsが空リストの場合とそうじゃない場合に場合分けする。

(define (map-take f pos lis1 . lists)
 (if (null? lists)
  ...))

listsが空リストの場合は前出のロジックと同じだ。

(define (map-take f pos lis1 . lists)
 (if (null? lists)
  (let loop ((lis lis1) (acc '()))
   (with-handlers ((exn:fail:contract?
           (lambda (exn) (reverse acc))))
    (loop (cdr lis) (cons (f (take lis pos)) acc))))
 ...)

listsが空リストじゃない場合、lis1listsconsして一つのリストにしてしまって、SRFI-1のzipを使って要素番号が一致している要素を一つのリストにまとめてしまう。そしてlistsが空リストのmap-takeを再帰的に呼び出してしまう。

(require (only-in srfi/1 zip))

(define (map-take f pos lis1 . lists)
 (if (null? lists)
  (let loop ((lis lis1) (acc '()))
   (with-handlers ((exn:fail:contract?
           (lambda (exn) (reverse acc))))
    (loop (cdr lis) (cons (f (take lis pos)) acc))))
  (map-take f pos (apply zip (cons lis1 lists)))))

複数のリストを引数として取れば、次のようになる。

> (map-take identity 2 '(1 2 3 4 5) '(6 7 8 9 10))
'(((1 6) (2 7)) ((2 7) (3 8)) ((3 8) (4 9)) ((4 9) (5 10)))

あとは、引数のfが関数であるかどうかをチェックしておけば完璧だ。

(define (map-take f pos lis1 . lists)
 (if (null? lists)
  (when (procedure? f)
   (let loop ((lis lis1) (acc '()))
    (with-handlers ((exn:fail:contract?
            (lambda (exn) (reverse acc))))
     (loop (cdr lis) (cons (f (take lis pos)) acc)))))
  (map-take f pos (apply zip (cons lis1 lists)))))

件の問題のような、「ラテン方陣」のデータが欲しい場合は次のようにすればいい。

> (map-take identity 3 '(1 2 3 1 2 3))
'((1 2 3) (2 3 1) (3 1 2) (1 2 3))

実際、一つ項が多いが、それこそtakeを適用すればいいだけ、の話だ。

> (take (map-take identity 3 '(1 2 3 1 2 3)) 3)
'((1 2 3) (2 3 1) (3 1 2))

map-takeと言うユーティリティを作成しておけば、ラテン方陣のデータ作成なぞ、それこそ赤子の手をひねるように書く事が出来る。

同様に、副作用版として、take-for-eachと言う関数もmap-takeをベースとして改造で作る事が可能だ。

(define (take-for-each proc pos lis1 . lists)
 (if (null? lists)
  (when (procedure? proc)
   (let loop ((lis lis1))
    (with-handlers ((exn:fail:contract?
            (lambda (exn) (
void))))
     (proc (take lis pos))
     (loop (cdr lis)))))
  (take-for-each proc pos (apply zip (cons lis1 lists)))))

take-for-eachは副作用目的なので、名前付きletに計算結果を保持しておくアキュムレータ(acc)を定義しておく必要がない。実行したら消えて構わないから、だ。
Racketのvoid関数は「返り値は何もない」事を返す関数だ。これでtakeが例外を投げてきた時、「何もしない」事を結果とする事が出来る。
また、with-handlersは本体部分に、「暗黙のbegin」が含まれてるんで、関数の実行、そして再帰をそのまま逐次実行出来る。特にbeginで包む必要はない。

> (take-for-each display 3 '(1 2 3 1 2 3))
(1 2 3)(2 3 1)(3 1 2)(1 2 3)

さて、どうだったろう。これがリスト内包表記とまた違う方向のmapのさらなる抽象化の一例だ。
この考え方を使えば、take-reduceなんかのユーティリティを書く事も可能だろう。その辺は宿題にしておく。

今回のソースコードはコチラに置いておく。 => map-take, take-for-each

※1: 2003年に制定されたJIS Cの言語仕様では、実は暗黙には次のようなルールが存在する。

  1. 整数型intと言う型はmain関数の返り値の型として、しか使わない。プログラム中で整数を使う場合はinttypes.hを#includeする事。ここで定義された整数型を使用すべきで、intやlongなぞは使ってはダメだ。ここでは8bit幅で符号無しのuint8_t型の配列を宣言している。
  2. forで使用するカウンタには整数型は用いない事。代わりにsize_t型を用いる。
  3. 整数を出力する際、%dなんかの書式指定子は用いない。ギョッとするだろうが、PRI何とか、と言うマクロを用いて出力する事。
整数を使うのにわざわざinttype.hを#includeする、なんぞクッソメンド臭いんだけど、実はこれが(あまりハッキリと言われてはいないが)C99以降の「作法」なんだ。
この「メンド臭さ」を要求するのが現行のCのアレな部分、ってのは事実で、ついつい「だったらANSI Cの書き方で・・・」って思っちゃうのは責められない、ってば責められない部分がある。そしてANSI Cの書き方が「通用する」のはある程度事実なんだ。
しかし、ANSI Cの書き方で通用するのが何故か、と言うのは、あくまで「レガシーなCコードをコンパイルする」時に問題が生じないように、要は「後方互換性」の為にそうしてるわけだ。人がそれで書くことを推奨してるわけじゃない
このブログでは以降、なるたけC23に通じるような「未来的な」C言語での記法にしようか、と思ってる(泣きそうだが・笑)。
  • Xでシェアする
  • Facebookでシェアする
  • はてなブックマークに追加する
  • LINEでシェアする

最近の「プログラミング」カテゴリーもっと見る

最近の記事
バックナンバー
人気記事