見出し画像

Retro-gaming and so on

PythonのmapとLisp他関数型言語のmap

教えて!gooに面白い質問が上がっていた。

Win10でpython3.10 勉強中。
map利用例でこんな現象あり、私だけ? それともこれが仕様?
#map(関数、リストやタプルなどのオブジェクト) 要素全てに同じ関数適用できる機能
今回の問題提起は、下記の現象です。
mapを使って、その結果の利用1回目OKなのに、2回目以降使えない。

a = [0,1,2,3]
def multi(x):
 y = x*2
 return y
aa = map(multi, a)
print(aa) #<map object at 0x000001F335A9A5F0>
print(list(aa)) #map結果利用1回目 [0, 2, 4, 6,]
print(list(aa)) #map結果利用2回目の結果は []
print(len(list(aa))) #0 なんと2回目以降にmap結果を使うことができない!!!

b = list(aa) #代入してみた
print(b) #[] 結果は同じ 2回目以降map結果使えない
print(list(aa)) #[] 結果は同じ 2回目以降map結果使えない
print(aa) #<map object at 0x000001F335A9A5F0>は変わらない

#リスト内包表記では?
print(a) # [0,1,2,3]
b = [i*2 for i in a]
print(b) # [0,2,4,6] リスト内包表記では、何度も使える

#再度map挑戦
aa = map(multi, a)
c = [i for i in list(aa)] #map結果の利用1回目
print(c) #[0,2,4,6] 目的通りの結果
d = [i for i in list(aa)] #map結果利用2回目 下記のように結果は[]
print(d)                #[] 使えない

これはなかなか面白い質問だ。そしてこの質問者の「戸惑い」ってのは全くその通りだ。
例えば、Racketだと、

(define *a* '(0 1 2 3))

(define (multi x)
 (* x 2))

(define *aa* (map multi *a*))

とした場合、

> (display *aa*)
(0 2 4 6)
> (display *aa*)
(0 2 4 6)
> (display *aa*)
(0 2 4 6)
> (display (length *aa*))
4
>

と、何度もmapを束縛した大域変数 *aa* は使い回しが可能だ(※1)。
これは当然OCamlでも同じで、

let a = [0; 1; 2; 3]

let multi x =
 x * 2

let aa = List.map multi a

としたら

utop[0]> List.iter (printf "%d ") aa;;
0 2 4 6 - : unit = ()
utop[1]> List.iter (printf "%d ") aa;;
0 2 4 6 - : unit = ()
utop[2]> List.iter (printf "%d ") aa;;
0 2 4 6 - : unit = ()
utop[3]> printf "%d " (List.length aa);;
4 - : unit = ()
utop[4]>

と、変数に束縛されたmapは何度も使い回しをする事が出来る。
関数型言語観点では、この質問者の直感は正しくて、Pythonのmapは「おかしな挙動」を見せるのだ。

実際は、Pythonのmapはリストのみならずイテレータを引数に取り、無限長の長さを持つイテラブルでも問題なく処理してくれる。
しかしながら、これはホンモノの遅延評価ではない。Racketのstream-mapでも束縛された値は使い回す事が可能だ。

(define *a* (stream 0 1 2 3))

(define (multi x)
 (* x 2))

(define *aa* (stream-map multi *a*))

としておいて、

> (display (stream->list *aa*))
(0 2 4 6)
> (display (stream->list *aa*))
(0 2 4 6)
> (display (stream->list *aa*))
(0 2 4 6)
> (display (stream-length *aa*))
4
>

となる。
問題はPythonのmapの場合、「計算結果を消費していく」事にある。つまり、

a = [0, 1, 2, 3]

def multi(x):
 return x * 2

aa = map(multi, a)

とした場合、

>>> aa.__next__()
0
>>> aa.__next__()
2
>>> aa.__next__()
4
>>> aa.__next__()
6
>>> aa.__next__()
Traceback (most recent call last):
File "<pyshell#4>", line 1, in <module>
aa.__next__()
StopIteration
>>>

と、実は内部的には破壊的変更が行われてるのだ。
何度も言うが、副作用がある、と言う事は、思わぬ結果を招く、と言う好例の1つだろう。そういう意味では、PythonのmapはLispや他の関数型言語のmapとは明らかに性質が違うんだ。
例えば、OCamlの場合、mapは、そのものではないにせよ、次のような再帰的定義になっている。

let rec our_map proc lst = match lst with
  [] -> []
   | first::rest -> (proc first) :: (our_map proc rest)

ここには副作用が全くない。
Schemeの場合、mapのリスト引数は可変長引数の為、定義が若干複雑にはなるが(※2)、やっぱり基本的な実装は同じだ。

(define our-map
 (letrec ((f (lambda (proc xs acc)
      (if (null? xs)
       acc
       (f proc (cdr xs) `(,@acc ,(proc (car xs)))))))
    (g (lambda (proc xss acc)
      (if (member '() xss)
       (f (lambda (x)
         (apply proc x)) acc '())
       (g proc (f cdr xss '()) `(,@acc ,(f car xss '())))))))
 (case-lambda
  ((proc arg) (f proc arg '()))
  ((proc . args) (g proc args '())))))

あくまで再帰的に処理されてるだけで、破壊的変更を含む副作用は行われていない。
仮にPythonのmapも・・・Pythonは再帰が苦手なんで、リスト内包表記を使って書いたとしても、次のように書けば、「計算結果がそのまま残る」事にはなるだろう(※3)。

def our_map(proc, *lst):
 return [proc(*item) for item in zip(*lst)]

そして、次のように設定して

a = [0, 1, 2, 3]

def multi(x):
 return x * 2

def our_map(proc, *lst):
 return [proc(*item) for item in zip(*lst)]

aa = our_map(multi, a)

実行すると、他の関数型言語のように動く。

>>> print(aa)
[0, 2, 4, 6]
>>> print(aa)
[0, 2, 4, 6]
>>> print(aa)
[0, 2, 4, 6]
>>> print(len(aa))
4
>>>

しかし、実際には恐らく次のように書かれている。

def our_map(proc, *lst):
 for item in zip(*lst):
  yield proc(*item)

これだと次のように設定して、

a = [0, 1, 2, 3]

def multi(x):
 return x * 2

def our_map(proc, *lst):
 for item in zip(*lst):
  yield proc(*item)

aa = our_map(multi, a)

実行すると、質問の状況と同じ様になることが分かるだろう。

>>> print(aa)
<generator object our_map at 0x7fb85fa83120>
>>> print(list(aa))
[0, 2, 4, 6]
>>> print(list(aa))
[]
>>> print(list(aa))
[]
>>> print(len(list(aa)))
0
>>>

繰り返すが、ジェネレータ及びイテレータは、「無限に値を生成する」イテラブルを受け取れるが、同時に破壊的変更で値を生成し、それを消費する。
つまり、これは別にホンモノの遅延評価じゃあないんだ。
その辺をキチンと押さえておこう。
なお、こういう風にジェネレータ/イテレータを束縛して使いまわしたい、と言った場合、無引数ラムダ式で包んで、サンク(thunk)にしておくのがテクニックだと言える。

>>> aa = lambda: map(multi, a)
>>> print(list(aa()))
[0, 2, 4, 6]
>>> print(list(aa()))
[0, 2, 4, 6]
>>> print(list(aa()))
[0, 2, 4, 6]
>>> print(len(list(aa())))
4
>>>

※1: 厳密に言うと、当然、mapを使った「関数実行」が束縛されてるわけではなく、あくまで「関数を実行した結果が」束縛されている。
Pythonの問題は、aaに「イテレータと言う結果が」束縛されてはいるが、これを何らかのカタチで「具現化」(例えば質問のケースではlist化する)したら後が残らない、と言うのがポイントなわけだ。

※2: ここでは、Scheme仕様上の「もっとも基本的な機能だけを使って」実装しているので、若干複雑になっている。
もちろん、Racketの他の機能やSRFI-1の機能を使えばもっと短く書く事は可能だろうが、その「他の機能」自体がmapから作られてるケースが多い為、言わば「鶏が先か、卵が先か」のような、それこそ再帰的な状況になってしまうため、ここでは基本機能のみを使う事とした(要するにmapを自作実装してるのに、組込みのmapからの発展機能を使えば「そりゃないだろ」「反則だろ!」と言う話になる)。
なお、map自体もreduce/foldで実装可能な為、実の事を言えばreduce/foldの方がより基本的な高階関数だ、と言う事になる。
例えば左側から畳み込むとすれば、map

(define (our-map proc . args)
 (reverse (apply foldl (lambda x
           (let* ((pos (- (length x) 1)) (y (take x pos)))
            (cons (apply proc y) (last x)))) '() args)))

こう書けるし、右から畳み込むとすれば

(define (our-map proc . args)
 (apply foldr (lambda x
       (let* ((pos (- (length x) 1)) (y (take x pos)))
        (cons (apply proc y) (last x)))) '() args))

と書く事が出来る。

※3: *は、C言語を学んだ人はポインタと勘違いするだろうが、ポインタではない(過去、実際、教えて!gooの質問者で勘違いしてた人がいた)。
しかし、関数定義の際には*がついた引数は可変長引数を現し、つまりある意味リストでパッキングしてるのに、関数の実引数で使う際には「アンパッキング」、つまりリストのカッコを外す作用になってる、と言うのはポインタ並にかなり紛らわしい。
ちなみに、このようなアンパッキングの文法が成立したのはPython3になってから、だ。
  • Xでシェアする
  • Facebookでシェアする
  • はてなブックマークに追加する
  • LINEでシェアする

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

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