Skip to content

Instantly share code, notes, and snippets.

@otaon
Last active May 27, 2024 02:51
Show Gist options
  • Save otaon/8959c4f113fefe8a0e5338321f630cec to your computer and use it in GitHub Desktop.
Save otaon/8959c4f113fefe8a0e5338321f630cec to your computer and use it in GitHub Desktop.
継続とクロージャと末尾再帰
(declaim (optimize (debug 3)))
(defun last-element-p (lst)
(if (cdr lst)
t
nil))
(defun add-elements (lst)
"非末尾呼び出し形式&非継続渡し形式"
(break)
(if (last-element-p lst)
(+ (car lst)
(add-elements (cdr lst)))
(car lst)))
(defun add-elements/cps (lst cont)
"末尾呼び出し形式&継続渡し形式"
(break)
(if (last-element-p lst)
(add-elements/cps (cdr lst)
#'(lambda (m)
(funcall cont (+ (car lst) m))))
(funcall cont (car lst))))
;; 呼び出し数が少なければcpsでなくてもスタックオーバーフローしない
(progn (defparameter *small-list* (make-list 5 :initial-element 1))
(prin1 (add-elements/cps *small-list* #'values)
(prin1 (add-elements *small-list*))))
;; 呼び出し数が多いとcpsでないとスタックオーバーフローになる
(progn (defparameter *large-list* (make-list 50 :initial-element 1))
(prin1 (add-elements/cps *large-list* #'values)
(prin1 (add-elements *large-list*))))

主旨

本稿は、Schemeなどの言語で明示的にサポートされている 継続(continuation) という概念と、それにまつわる諸概念の学習記録である。

本稿のゴール

  • プログラミング言語の種類によらず、再帰関数の作成時に、継続渡し方式(cps: continuation passing style)を応用できるようになる。
    • なんらかの再帰関数を、継続渡しを用いた形にリファクタリングできるようになる。
    • 非末尾再帰形の再帰関数を、末尾再帰形の再帰関数にリファクタリングできるようになる。

勉強の参考サイト

継続については下記サイトを主軸に勉強した。とても分かりやすい。


一言・二言で説明

継続とは

継続とは,動作中に凍結したプログラムだ.
すなわち計算処理の状態を含んだ一つの関数的オブジェクトだ.
保存された計算処理は,それが中断された時点から再開する.

Paul Graham - On Lisp

継続とは、これから評価すべき環境(処理およびデータ)を、「後で評価可能な環境」として保持したもの。
後で評価可能な環境とは、「自由変数と束縛変数と処理」のことであり、これはすなわちクロージャのこと。

クロージャとは

(無名)関数とクロージャの違いは、lambda式内に自由変数があるか否か

[・ _ゝ・]日記を書くはやみずさん- クロージャと記号論理学と高校数学

プログラミングにおいては、自由変数とは関数の中で参照される局所変数や引数以外の変数を意味する。

Wikipedia - 自由変数と束縛変数

クロージャとは、関数が保持する情報の他に、自由変数(グローバル変数や、関数の外にある変数の値)も保持できるもののこと。
したがって、クロージャを使えば、クロージャを定義した時点での環境を包み込んで、それを継続として扱える。
下記コードだと、 addn がクロージャである(自由変数nを束縛するため)。

  • クロージャの例
def add1(): # クロージャを生成する関数
    n = 1
    def addn(x): # クロージャ(addnの外側にある変数(自由変数)nを束縛するため)
        return x + n
    return addn

f = add1()
print(f(5))  # 6
print(f(10)) # 11

ちなみに、クロージャの名前の由来は、 開いた関数閉じた関数 にすることから。
cf. ある論理式Aが自由変数を含むとき、 論理式Aは開いている といい、自由変数を1つも含まないとき、 論理式Aは閉じている という。
→ (関数 | 論理式)が(関数の中で参照される局所変数や引数以外の変数 | 自由変数)を1つも含まないとき、(関数 | 論理式)は閉じている。

末尾再帰とは

ある関数内で再帰的に同じ関数を呼び出したとき、呼び出し先から呼び出し元に戻ってきた後に行われる処理が何もなければ末尾再帰である。

  • 末尾再帰の例
def sum(x, acc):
    """
    1からxまでの総和を求める(末尾再帰)
    """
    if x == 0:
        return acc
    else
        sum(x - 1, acc)

sum(10, 0) # 55
  • 非末尾再帰の例
def sum(x):
    """
    1からxまでの総和を求める(add呼び出しの後に+演算があるため末尾再帰ではない)
    """
    if x == 0:
        return 1
    else
        return x + sum(x - 1)

sum(10) # 55

継続・継続呼び出し・末尾呼び出し最適化の詳細とそれらの関係性

継続・継続渡しとは

ある関数 function があるとする。この function の任意の処理の間で分断する。
このとき、分断からの処理を実行するには、分断までの環境(変数の値等)が必要となる。
この情報のことを 継続 という。

global_a = 10

def function():
    a, b, c = 1, 2, 3
    x = a
    x = x + b
# ---------------ここで分断する
    x = x + c
    return x + global_a

function() # 16 (= 1 + 2 + 3 + 10)

次に、関数呼び出しがある場合の継続について考える。
この場合も同様で、 function2 の処理が終わった後に function1 の残りを処理するため、function2 呼び出しまでの環境を覚えておく必要がある。
このとき、 function2 から function1 へ戻るための情報(function1の続きへのアドレス、戻り値、変数の値等)が 継続 である。

def function1(x):
    y = 1
    z = []
    function2(10, z) # ---------------ここで分断する
    return x + y + z[0]

def function2(x, z):
    z[0] = 2 * x

function1(1) # 31 (= 10 + 1 + (2 * 10))

一般的な手続き型言語における継続は、下図のように表せる。
図の通り、関数呼び出しがある度、呼び出し元の続きを処理するために 暗黙的に 継続が作られる。
ここで、 func1 の継続には、 func0 の継続が含まれていることに注意する。

cf. C系の言語においては、コールスタックとして継続を保持している。
コールスタックは言語のユーザに意識させないようになっているため、継続は 暗黙的に 作られるといえる。

  • 暗黙的な継続の利用の図
+------------+
| func0      |
| call func1 |
+------------+
*     |
*     +------------+
*                  |
*                  v
*            +------------+
*            | func1      |
*            | call func2 |
*            +------------+
*            *     |
*            *     +------------+
*            *                  |
*            *                  v
*            *            +------------+
*            *            | func2      |
*            *            |            |
*            *            | return     |
*            *            +------------+
*            *                  :
*            *     +............+
*            *     :
*            next1 v
*            +------------+
*            | return     |
*            +------------+
*                  :
*     +............+
*     :
next0 v
+------------+
| return     |
+------------+

legend -------------------------------------------------------

+----------------+        call       +----------------+
| funcA          |------------------>| funcB          |
|                |                   | process        |
| return         |<..................| return         |
+----------------+       return      +----------------+
*
* duration of retention of continuation of funcA
*
nextA
+----------------+
|                |
+----------------+

ここで、関数を呼び出した際、引数だけでなく、 明示的に 継続(評価可能な環境)を呼び出し先の関数に渡すことを考えると、下図のようになる。
なお、継続を渡して最後に評価するという考えにおいては、return (呼び出し元へ戻る)という概念は存在しないことに注意する。
このように、関数呼び出しの際に、継続も一緒に渡すことを 継続渡し(continuation passing) という。

  • 継続渡しの図
+----------------------------+
| func0                      |
| call func1                 |
+----------------------------+
              | (continuation of func0)
              +-------+
                      |
                      v
        +----------------------------+
        | func1                      |
        | call func2                 |
        +----------------------------+
                      | (continuation of func1, continuation of func0)
                      +-------+
                              |
                              v
                +----------------------------+
                | func2                      |
                | call continuation of func1 |
                +----------------------------+
                              | (continuation of func0)
                      +-------+
                      |
        next          v
        +----------------------------+
        | continuation of func1      |
        | call continuation of func0 |
        +----------------------------+
                     |
             +-------+
             |
next         v
+----------------------------+
| continuation of func0      |
+----------------------------+

継続渡しと末尾呼び出し最適化

末尾再帰とは、再帰呼出しのうち、再帰呼出し以降に処理がないタイプのことをいった。

ここで、上図の func1 の継続を考える。
func2 から func1 のnextへ処理が帰ってきたとき、 func1 のnextの処理が、 func0 へ帰ることのみならば、わざわざ func1 のnextに帰らず、 func0 のnextへ直接帰れば良い。
言い換えれば、 func1 の継続を使用しない場合、 func2 には func0 の継続のみを渡せば良い。
同様に、N段階の再帰においても、各々の継続が呼び出し元へ帰ることのみであれば、その継続は不要であるため、最初の関数の継続を渡すのみで良い。

ここで、最初の継続のみ渡せば良い場合、各再帰呼び出しによってスタックを使用する必要がない。
末尾呼び出し最適化 とは、コンパイラが末尾呼び出しを見つけたら、スタックを使用しないように内部の処理を最適化し、再帰呼び出しによってスタックオーバーフローを起こさないようにすることをいう。

pythonによる末尾呼び出し最適化可能なコードを以下に示す。
caution: ただし、pythonにおいてこのコードは末尾呼び出し最適化されない。pythonは末尾呼び出し最適化をサポートしていないため、関数呼び出しのたびに継続を作ってしまう。

def sum(x, continuation):
    """1からxまでの総和を求める"""
    def temp(y):
        continuation(y + x)

    if x > 1:
        sum(x - 1, temp)
    else:
        continuation(x)
    
sum(10, print) # 55

上記のコードを見ると、クロージャ temp は、自由変数 x を内包し、 y が入力されたら出力が定まる。
さらに、上記のコードの処理を関数呼び出し毎に確認していくと、継続がどのように作られていくのかがよく分かる。
ここでは、 sum(3, print) を呼び出した場合を確認してみることにする。

  1. sum(3, print)
    最初に sum を呼び出す際、呼び出し元では、その sum を評価した後に実行したい処理を引数に継続として渡す。
    ここでは、「sumの評価結果を表示する」ために printsum に渡している。
    また、もちろん sum の入力である x にも 3 を渡している。
sum(3, print)
  1. def sum(3, print): ...
    0.で、 x3 に束縛され、また、 continuationprint に束縛されている。
    これにより、 temp は、「入力に3を足して表示する」関数として定義される。
    さらに、 if のtrue節に入るため、次の sum(3 - 1, temp) <=> sum(2, temp) を呼び出す。
def sum(x, continuation):
# def sum(3, print):
    """1からxまでの総和を求める"""
    def temp(y):
    # def temp1(y): 「入力+3を表示する関数」
        continuation(y + x)
        # temp1(y) => print(y + 3)
 
    if x > 1:
    # if 3 > 1:
        sum(x - 1, temp)
        # sum(3 - 1, temp1) # 評価される
    else:
        continuation(x)
        # print(3)  # 評価されない
  1. def sum(2, temp1): ...
    基本的に1.と同様。 temp の定義では、内部で最後に continuation を評価するようにしてある。
    これで、関数実行後に継続を評価することになる。
def sum(x, continuation):
# def sum(2, temp):
    """1からxまでの総和を求める"""
    def temp(y):
    # def temp2(y): 「「入力+3を表示する関数」に入力+2を渡して評価する関数」
        continuation(y + x)
        # temp2(y) => temp1(y + 2)
        #          => print((y + 2) + 3)
 
    if x > 1:
    # if 2 > 1:
        sum(x - 1, temp)
        # sum(2 - 1, temp2) # 評価される
    else:
        continuation(x)
        # continuation(2) => temp1(2)
        #                 => print(2 + 3)  # 評価されない
  1. def sum(1,temp2): ...
    最後の再帰呼出し。上記までに作成した継続を評価する。これにより、再帰呼び出し内の処理が終わった後に、残っていた処理を全て処理してしまう。
def sum(x, continuation):
# def sum(1, temp):
    """1からxまでの総和を求める"""
    def temp(y):
    # def temp3(y): 「「「入力+3を表示する関数」に入力+2を渡して評価する関数」に入力+1を渡して評価する関数」
        continuation(y + x)
        # temp3(y) => temp2(y + 1)
        #          => temp1((y + 1) + 2)
        #          => print(((y + 1) + 2) + 3)
 
    if x > 1:
    # if 1 > 1:
        sum(x - 1, temp)
        # sum(1 - 1, temp3) # 評価されない
    else:
        continuation(x)
        # continuation(1) => temp2(1)
        #                 => temp1(1 + 2)
        #                 => print((1 + 2) + 3) # 評価される
  1. continuation(x)
    継続を処理する。この例を見て分かるように、継続渡しによる末尾再帰は、結局は再帰関数内でのクロージャの作成とその実行によって実現される。
print((1 + 2) + 3) # 6

継続渡しによる末尾最適化済みのプログラムは下図のように表せる。

+----------------------------+
| func0                      |
| call func1                 |
+----------------------------+
              | (continuation of func0)
              +-------+
                      |
                      v
        +----------------------------+
        | func1                      |
        | call func2                 |
        +----------------------------+
                      | (continuation of func0)
                      +-------+
                              |
                              v
                +----------------------------+
                | func2                      |
                | call continuation of func1 |
                +----------------------------+
                              |
              +---------------+
              |
next          v
+----------------------------+
| continuation of func0      |
+----------------------------+

ちなみに、上図を見て分かる通り、末尾呼び出し最適化済みの処理は、再帰構造になっておらず、継続を渡していくことによる、ただの処理の連鎖になる。
したがって、末尾再帰最適化よりも、末尾呼び出し最適化の方が本質をついた呼び方だと言える。


継続渡しによる末尾呼び出し最適化を実践する

末尾呼び出し最適化がされていない関数を、末尾呼び出し最適化された関数にリファクタリングする手順を以下に示す。

例1: 単純な再帰を継続渡し方式(cps)にリファクタリングする

例として、リスト内の全要素を足し合わせる add-elements 関数をcpsにリファクタリングする。

  • add-elements 関数仕様
    • 入力: 加算可能な数値が入ったリスト 例: (1 2 3 4)
    • 出力: リスト内の全要素の総和 例: 10
    • その他: デバッグのため break を設定する。

最終型

先に、上記仕様の関数をコードに落とし込んだ結果を示す。

  • 関連データ・関数
;; デバッグ処理を有効化
(declaim (optimize (debug 3)))

;; ヘルパー関数
(defun two-or-more-p (lst)
  "リスト内に2個以上要素が入っているか?"
  (if (cdr lst)
      t
      nil))

;; 少ないリスト数
(progn (defparameter *small-list* (make-list 5 :initial-element 1))
       nil)

;; 多いリスト数(継続渡し方式でないとスタックオーバーフローするくらい大きなサイズとする)
(progn (defparameter *large-list* (make-list 5000000 :initial-element 1))
       nil)
  • 末尾呼び出し最適化されていない関数
(defun add-elements (lst)
  "非末尾呼び出し形式&非継続渡し形式"
  (break)
  (if (two-or-more-p lst)
      (+ (car lst)
         (add-elements (cdr lst)))
      (car lst)))

(prin1 (add-elements *small-list*)) ; 呼び出し数が少なければcpsでなくてもスタックオーバーフローしない
(prin1 (add-elements *large-list*)) ; 呼び出し数が多いとスタックオーバーフローする
  • 継続渡し形式(末尾呼び出し形式)にした関数
(defun add-elements/cps (lst cont)
  "末尾呼び出し形式&継続渡し形式"
  (break)
  (if (two-or-more-p lst)
      (add-elements/cps (cdr lst)
                        #'(lambda (m)
                            (funcall cont (+ (car lst) m))))
      (funcall cont (car lst))))

(prin1 (add-elements/cps *small-list* #'values))
(prin1 (add-elements/cps *large-list* #'values))  ; 呼び出し数が多くてもcpsであるからオーバーフローしない

最初は素直に再帰関数を実装する

add-elements の仕様は、下記のとおりに解釈できる。

  • 全要素の総和とは、「ある要素」を、「以降の要素の総和」と足し合わせたものである
  • 要素1つのみの総和とは、その要素自身である

見て分かるとおり、この解釈は、数学的帰納法としての解釈にほぼ等しい。

  • sum(list) := car(list) + sum(cdr(list))
  • sum(element) := element

これを素直にコードに落とし込むと、下記の通りになる。

(defun add-elements (lst)
  "非末尾呼び出し形式&非継続渡し形式"
  (break)
  (if (two-or-more-p lst)
      (+ (car lst)
         (add-elements (cdr lst)))
      (car lst)))

このコードは、再帰呼び出しした後、その結果を (car lst) と足し合わせる処理がある。
したがって、再帰呼び出しした際、その呼び出し元の継続が暗黙的に作成されてしまう。

末尾呼び出し最適化の方針を決める

ここで、継続が暗黙的に作成されないように修正する方法は下記の2種類ある。

  • 再帰呼び出しの後に何も処理がないように、関数のアルゴリズム自体を書き換える。
  • 再帰呼び出しの後にすべき処理をクロージャに包み込んで、再帰呼び出し先に一緒に渡してしまい、暗黙的な継続の作成を阻止する。

上の例のように簡単な関数のアルゴリズムであれば、関数のアルゴリズムを書き換えたほうが簡単である。
実際に、アルゴリズムを書き換えたものは下記のようにも修正できる。
このとき、引数に渡した値は accumulator といい、計算の途中結果を意味する。

  • accumulator を用いた末尾呼び出し
(defun add-elements (lst &optional (acc 0))
  "非末尾呼び出し形式&非継続渡し形式"
  (break)
  (if (two-or-more-p lst)
      (add-elements (cdr lst) (+ (car lst) acc))
      (+ (car lst) acc)))

簡単なアルゴリズムの場合、上記のように書き換えるのは簡単である。
しかし、複雑なアルゴリズムの場合 継続渡し方式ならば、ほぼ機械的に末尾呼び出しに変形できる。
したがって、継続渡し方式の方がより汎用性があるといえる。

以下では、継続渡し方式による末尾呼び出し最適化を行うことにする

戻り値を返す箇所を継続の呼び出しに変更する

上記の関数を継続渡し方式に修正する手順を以下に示す。

1. 「関数の処理完了後に評価されるべき継続」(以下「継続」という)を束縛するための仮引数を用意する

仮引数に、継続渡しのための仮引数を追加する。
継続を意味する変数を cont (continuationの略)とする。

(defun add-elements (lst cont) ; 1.
  "非末尾呼び出し形式&非継続渡し形式"
  (break)
  (if (two-or-more-p lst)
      (+ (car lst)
         (add-elements (cdr lst)))
      (car lst)))

2. 「継続」を、呼び出し先の関数に引数として渡す

2-1. 修正前の関数において、再帰呼出し後に実行する処理を「継続」として抽出する

この関数における「継続」は (+ (car lst) (add-elements (cdr lst))) である。つまり、「add-elementsの評価結果を(car lst)と足す」である。
これをクロージャに包み込むと、 #'(lambda (<add-elementsの戻り値>) (+ (car lst) <add-elementsの戻り値>)) となる。
実際のコードとしては、例えば add-elementsの戻り値m とおいて #'(lambda (m) (+ (car lst) m)) とすれば良い。

:NOTE: ここで、 lst は自由変数であるため、 lst は、ひいては (car lst) は、クロージャの定義時に評価された値に束縛されることに注意する。
つまり、lst は、クロージャを定義した時点のリスト自体を表すことになる。

(defun add-elements (lst cont) ; 1.
  "非末尾呼び出し形式&非継続渡し形式"
  (break)
  (if (two-or-more-p lst)
      (+ (car lst)                  ;; 2-1. (add-elements)呼び出し後の本関数の処理(=継続)は、「add-elementsの評価結果を(car lst)と足す」である
         (add-elements (cdr lst)))  ;;      => 継続をクロージャとすると #'(lambda (add-elementsの戻り値) (+ (car lst) add-elementsの戻り値))
      (car lst)))
2-2. 抽出した「継続」を、再帰呼出しする際に、引数 cont に渡す

2-1. で抽出した継続を、(add-elements) の引数に渡す。
こうすることで、 (add-elements) の評価の末尾で、渡した継続が評価されることになる。

(defun add-elements (lst cont) ; 1.
  "非末尾呼び出し形式&非継続渡し形式"
  (break)
  (if (two-or-more-p lst)
      (add-elements (cdr lst) #'(lambda (m) (funcall cont (+ (car lst) m)))) ; 2-2.
      (car lst)))

3. 最終的に戻り値を返すのではなく、継続を呼び出すように変更する

再帰呼び出しの最後で、もともと戻り値を返していた箇所を、その戻り値を入力にして継続を評価するように変更する。
これによって、今までクロージャに内包し続けてきた継続を全て評価しきることになる。

(defun add-elements (lst cont) ; 1.
  "末尾呼び出し形式&継続渡し形式"
  (break)
  (if (two-or-more-p lst)
      (add-elements (cdr lst) #'(lambda (m) (funcall cont (+ (car lst) m)))) ; 2-2.
      (funcall cont (car lst)))) ; 3.

これで、非再帰呼び出しの形式から、継続渡し方式による末尾呼び出し形式にリファクタリングできた。

例2: 関数内で再帰呼び出しを2回行う関数を継続渡し方式(cps)にリファクタリングする

例として、木の全ての葉の個数を足し合わせる count-leaf 関数をcpsにリファクタリングする。

  • count-leaf 関数仕様
    • 入力: コンスセルをノードとして構成された木 例: ((a . b) . ((c . d) . e))
    • 出力: 木の全ての葉の個数の総数 例: 5
    • その他: デバッグのため break を設定する。
                           +-------+
                           |   |   |
                           +-------+
                             |   |
           +-----------------+   +-----------------+
           |                                       |
       +-------+                               +-------+
       |   |   |                               |   |   |
       +-------+                               +-------+
         |   |                                   |   |
 +-------+   +-------+                   +-------+   +-------+
 |                   |                   |                   |
'a                  'b               +-------+              'e
                                     |   |   |
                                     +-------+
                                       |   |
                                   +---+   +---+
                                   |           |
                                  'c          'd

最終型

先に、上記仕様の関数をコードに落とし込んだ結果を示す。

  • 関連データ・関数
;; デバッグ処理を有効化
(declaim (optimize (debug 3)))

;; ブランチが少ないツリー
(progn (defparameter *small-tree* (make-list 5 :initial-element 1))
       nil)

;; ブランチが多いツリー(継続渡し方式でないとスタックオーバーフローするくらい大きなサイズとする)
(progn (defparameter *large-tree* (make-list 5000000 :initial-element 1))
       nil)
  • 末尾呼び出し最適化されていない関数
(defun count-leaf (tree)
  "非末尾呼び出し形式&非継続渡し形式"
  (break)
  (if (consp tree)
      (+ (count-leaf (car tree))
         (count-leaf (cdr tree)))
      1))

(prin1 (count-leaf *small-tree*)) ; 呼び出し数が少なければcpsでなくてもスタックオーバーフローしない
(prin1 (count-leaf *large-tree*)) ; 呼び出し数が多いとスタックオーバーフローする
  • 継続渡し形式(末尾呼び出し形式)にした関数
(define count-leaf/cps (tree cont)
  (if (consp tree)
      (count-leaf/cps (car tree)
        #'(lambda (n)
            (count-leaf/cps (cdr tree)
              #'(lambda (m)
                  (funcall cont (+ n m))))))
      (funcall cont 1)))

(prin1 (count-leaf/cps *small-tree* #'values)) ; 呼び出し数が少なければスタックオーバーフローしない
(prin1 (count-leaf/cps *large-tree* #'values)) ; 呼び出し数が多くてもスタックオーバーフローしない

最初は素直に再帰関数を実装する

count-leaf の仕様は、下記のとおりに解釈できる。

  • あるノードにおける葉の数は、ノードのcar側の枝の下にある葉の数と、ノードのcdr側の枝の下にある葉の数の和である
  • ある葉における葉の数は、1である

この問題を解く計算式を考えると、下記のとおりになる。

  • count-leaf(node) := count-leaf(car(node)) + count-leaf(cdr(node))
  • dount-leaf(leaf) := 1

これを素直にコードに落とし込むと、下記の通りになる。

(defun count-leaf (tree)
  "非末尾呼び出し形式&非継続渡し形式"
  (break)
  (if (consp tree)
      (+ (leaf-count (car tree))
         (leaf-count (cdr tree)))
      1))

このコードも、再帰呼び出しを2回実行した後、その結果を足し合わせている。
したがって、再帰呼び出しした際、その呼び出し元の継続が暗黙的に作成されてしまう。

末尾呼び出し最適化の方針を決める

ここでも accumulator によって末尾呼び出しに修正できるが、やや面倒である。
例えば、以下のようにすれば、末尾呼び出しにできる。

  • accumulator を途中で見つけた葉の数を覚えておくために使用する。
  • 親ノードに戻れるようにするため、ツリー全体の情報と、現在位置(インスタンスへの参照)を引数に覚える。
  • ツリーのルートから深さ優先探索を行い、途中で葉を見つけたら accumulator をインクリメントする。

ただし、この方法は探索の各時点における情報を管理する必要があり、再帰呼出しの利点を活かせていない。
やはりこの場合、継続渡しを用いた末尾呼び出しへの修正を考えたほうが良い。

以下では、継続渡し方式による末尾呼び出し最適化を行うことにする

戻り値を返す箇所を継続の呼び出しに変更する

上記の関数を継続渡し方式に修正する手順を以下に示す。

1. 「関数の処理完了後に評価されるべき継続」(以下「継続」という)を束縛するための仮引数を用意する

仮引数に、継続渡しのための仮引数を追加する。
継続を意味する変数を cont (continuationの略)とする。

(defun count-leaf (tree cont) ; 1.
  "非末尾呼び出し形式&非継続渡し形式"
  (break)
  (if (consp tree)
      (+ (leaf-count (car tree))
         (leaf-count (cdr tree)))
      1))

2. 「継続」を、呼び出し先の関数に引数として渡す

2-1. 修正前の関数において、再帰呼出し後に実行する処理を「継続」として抽出する

この関数における「継続」は、やや複雑になる。というのも、再帰呼出しが2回あるため、再帰呼び出し後の継続の中に、さらに再帰呼び出しが内包される形になる。

2-1-1. 再帰呼び出しと継続を抽出する

再帰関数が作成する継続は2段階に分けられる。

  1. (count-leaf (car tree)) 再帰呼び出し自体
  2. (count-leaf (cdr tree)) 継続1
  3. (+ 0: 1:) 継続2
; 再帰呼び出し処理があるパスの、
; 再帰呼び出し自体&それ以降の処理
(+ (count-leaf (car tree))
   (count-leaf (cdr tree)))
; 再帰呼び出しと、それ以降の処理をステップ毎に列挙する
; 0: は再帰呼出し自体

; 0:
(count-leaf (car tree))
; 1:
(count-leaf (cdr tree))
; 2:
(+ 0:の戻り値 1:の戻り値)
2-1-2. 再帰呼び出しの「継続」仮引数部分に継続を渡す

再帰呼び出しのcont部分に継続を渡す。
(make-cont x:) は、処理 x: 以降の処理を内包するクロージャを作成する仮想的な関数である。
この仮想的な関数を導入することで、継続渡し方式への変換が後々楽になる。

; 0:
(count-leaf/cps (car tree) (make-cont 1:))
; 1:
(count-leaf/cps (cdr tree) (make-cont 2:))
; 2:
(+ 0:の戻り値 1:の戻り値)
2-1-3. 再帰呼び出し後の処理の最後に継続の呼び出しを追加する

再帰呼び出し後の処理の最後に、継続の呼び出しを追加する。
こうすることで、再帰呼出し後に、蓄積した継続を全て処理するようになる。

; 0:
(count-leaf/cps (car tree) (make-cont 1:))
; 1:
(count-leaf/cps (cdr tree) (make-cont 2:))
; 2:
(funcall cont (+ 0:の戻り値 1:の戻り値))
2-1-4. 最後の処理をクロージャに閉包する

抽出した複数の継続のうち、最後の継続、つまり2:をクロージャに閉包する。
ここで作成したクロージャの引数は、1:の戻り値を受け取る。
ここでは、1:の戻り値を m とする。

; 0:
(count-leaf/cps (car tree) (make-cont 1:))
; 1:
(count-leaf/cps (cdr tree) (make-cont 2:)) ; (count-leaf/cps (cdr tree))の戻り値をmとする
; 2:をcontとして閉包する
#'(lambda (m) ; m = 1:の戻り値
    (funcall cont (+ 0:の戻り値 m)))
2-1-5. 継続を遡っていき、順々にクロージャに閉包していく

再帰呼び出し後の最後の処理から前の処理に遡りつつクロージャに閉包していく。
ここで作成したクロージャの引数は、0:の戻り値を受け取る。
ここでは、0:の戻り値を n とする。

; 0:
(count-leaf/cps (car tree) (make-cont 1:)) ; (count-leaf/cps (car tree))の戻り値をnとする
; 1:
; contとして閉包する
#'(lambda (n) ; n = 0:の戻り値
    (count-leaf/cps (cdr tree) n))
; 2:
#'(lambda (m) ; m = 1:の戻り値
    (funcall cont (+ n m)))
2-1-6. 閉包したクロージャを最後の処理から遡りつつ入れ子にしていく

上記で別々に作成したクロージャをまとめていく。
まず、1:の中に2:を入れる。

; 0:
(count-leaf/cps (car tree) (make-cont 1:))
; 1:
#'(lambda (n) ; n = 0:の戻り値
    (count-leaf/cps (cdr tree)
      #'(lambda (m) ; m = 1:の戻り値
          (funcall cont (+ n m)))))

次に、0:の中に1:を入れる。
これで全てのクロージャを一つにまとめられた。
これがcpsの再帰呼び出しになる。

; 0: = count-leaf/cpsの継続
(count-leaf/cps (car tree)
  #'(lambda (n) ; n = 0:の戻り値
      (count-leaf/cps (cdr tree)
        #'(lambda (m) ; m = 1:の戻り値
            (funcall cont (+ n m))))))
2-2. 抽出した「継続」を、再帰呼出しする際に、引数 cont に渡す

関数呼び出し処理にcpsの再帰呼び出しを入れる。

(define count-leaf/cps (tree cont)
  (if (consp tree)
      (count-leaf/cps (car tree)
        #'(lambda (n) ; n = 0:の戻り値
            (count-leaf/cps (cdr tree)
              #'(lambda (m) ; m = 1:の戻り値
                  (funcall cont (+ n m))))))
      (funcall cont 1)))

これで、再帰呼出し処理を機械的にcpsに変換できた。


非末尾再帰を継続渡し形式によって末尾再帰に変換する手順

上記で実施した手順をまとめると、非末尾再帰を継続渡し形式によって末尾再帰に変換するルールが分かる。
このルールを以下に示す。

  1. 関数の仮引数に継続用の変数を追加する
  2. 関数の戻り値を返す部分を継続の呼び出し処理に修正する
  3. 再帰呼出し処理とその後の処理の部分を抽出する
  4. 再帰呼び出しの継続部分に継続を渡す
  5. 再帰呼び出し後の処理に継続の呼び出しを追加する
  6. 継続の最後の処理をクロージャに閉包する
  7. 他の処理も6.と同様にクロージャに閉包する
  8. クロージャを一つにまとめる
  9. 最初の再帰呼び出しに継続を代入する
  • コード例
(defun foo (n)
  (if (predicate)
      (bar (foo n) (foo n))
      (result)))

1. 関数の仮引数に継続用の変数を追加する

継続用の変数は continuation の略である cont をよく用いる。

(defun foo (n cont)
  (if (predicate)
      (bar (foo n) (foo n))
      (result)))

2. 関数の戻り値を返す部分を継続の呼び出し処理に修正する

関数の戻り値を返す部分を、戻り値を入力とした cont の呼び出し処理に置き換える。

(defun foo (n cont)
  (if (predicate)
      (bar (foo n) (foo n))
      (funcall cont result)))

3. 再帰呼出し処理とその後の処理の部分を抽出する

再帰呼出し処理と、その後の処理の部分を抽出する。

(bar (foo n) (foo n))

後で分かりやすくするために、各処理を分解しておく。
ここで、ある処理について、それ以前の処理の戻り値を使用している場合は、その戻り値を使用している箇所を明示しておく。

; 0:
(foo n)
; 1:
(foo n)
; 2:
(bar 0:の戻り値 1:の戻り値)

4. 再帰呼び出しの継続部分に継続を渡す

再帰関数の呼び出し箇所において、継続渡し用の仮引数に、その処理の次の処理を継続として渡すように明示する。
(make-cont x:) は、処理 x: 以降の処理を内包するクロージャを作成する仮想的な関数。

; 0:
(foo n (make-cont 1:))
; 1:
(foo n (make-cont 2:))
; 2:
(funcall cont (bar 0:の戻り値 1:の戻り値))

5. 再帰呼び出し後の処理に継続の呼び出しを追加する

再帰呼び出しの後の継続を全て評価するため、再帰呼び出し後の処理に継続の呼び出しを追加する。

; 0:
(foo n (make-cont 1:))
; 1:
(foo n (make-cont 2:))
; 2:
(funcall cont (bar 0:の戻り値 1:の戻り値))

6. 継続の最後の処理をクロージャに閉包する

継続の最後の処理をクロージャに閉包する。
このクロージャの引数には、クロージャに閉包する処理の前の処理の戻り値が渡されることになる。
こうすることで、クロージャが評価された時、その最後に、引数で渡された戻り値を使って継続が評価されることになる。

; 0:
(foo n (make-cont 1:))
; 1:
(foo n (make-cont 2:)) ; 1: (foo n) の戻り値をxとする
; 2:
#'(lambda (x) ; x = 1:の戻り値
    (funcall cont (bar 0:の戻り値 x)))

7. 他の処理も6.と同様にクロージャに閉包する

上記6.のクロージャへの閉包と同様に、各処理を最後から前に遡りながら、クロージャに閉包していく。

; 0:
(foo n (make-cont 1:)) ; 0: (foo n) の戻り値をyとする
; 1:
#'(lambda (y) ; y = 0:の戻り値
    (foo n (make-cont 2:)))
; 2:
#'(lambda (x) ; x = 1:の戻り値
    (funcall cont (bar y x)))

8. クロージャを一つにまとめる

上記で作成したクロージャを一つにまとめる。
(make-cont ?:) の部分に ?: を代入する。

  • 2:1:(make-cont 2:) に代入する。
; 0:
(foo n (make-cont 1:))
; 1:
#'(lambda (y) ; y = 0:の戻り値
    (foo n #'(lambda (x) ; x = 1:の戻り値
    (funcall cont (bar y x)))))
  • 1:0:(make-cont 1:) に代入する。
; 0:
(foo n
  #'(lambda (y) ; y = 0:の戻り値
      (foo n
        #'(lambda (x) ; x = 1:の戻り値
            (funcall cont (bar y x))))))

これで、再帰呼び出し後の継続を作成できた。

9. 最初の再帰呼び出しに継続を代入する

最初の再帰呼び出しの継続用の仮引数部分に、上記で作成した継続を代入する。

(defun foo (n cont)
  (if (predicate)
      (foo n
        #'(lambda (y) ; y = 0:の戻り値
            (foo n
              #'(lambda (x) ; x = 1:の戻り値
                  (funcall cont (bar y x))))))
      (funcall cont result)))

これで、非末尾再帰を継続渡し形式によって末尾再帰に変換できた。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment