traP Member's Blog

Co-data 【新歓ブログリレー2017 21日目】

phi16
このエントリーをはてなブックマークに追加

こんにちはphi16です。traPではプログラム担当です。今回はふしぎなおはなしをします。これもまた部内wikiから拾ってきたものです。




プログラムではいろんなデータを扱うと思います。いろんなデータに対して操作を行って新しいデータを作り、情報を抽出したり。
データとは何でしょうか。何を以ってデータと言うのでしょうか。いくら情報を持っているはずでもそれを取り出す方法がなければ情報が無いことと同じです。暗号などはこれを基礎として作られていますね。

逆に言えばデータというのは操作を伴って初めてデータとして存在しうるのです。

ならばデータとは操作そのものではないでしょうか。

まえがき

いわゆるデータ、Data に対して余データCodata と呼ばれる概念があります。これは一般のデータが有限であるのに対して無限の情報を表現するものです。無限の情報をどうやってコンピュータで表現すれば良いでしょうか?

内部は無限の情報であったとしても、データというのは本質的に操作によってでしか情報を取り出す事ができません。無限回の操作を行わなければ内部に無限の情報が含まれていることがわからないのです。「有限回の操作しか行わないこと」を以って無限のデータを実際に構築することができます。

例はそんなに難しくはありません。

struct Tree {
  Tree* left;
  int value;
  Tree* right;
};

Tree t;
t.left = &t;
t.value = 1;
t.right = &t;

tはどんな情報を持っているでしょうか?left,rightは自分自身を指していますが、アドレスという情報を知らなければこれは全てのノードに1を持つ無限の二分木と区別が付きません。

また、こんな例もあります。

var a = {};
a.value = 1;
a.next = a;

これも同じで、オブジェクト間の同等性を知ることができなければ無限に続く1の列と区別が付きません。

このようにして無限のデータを表現する、Codataの概念を獲得することができました。ではこれで何が出来るのでしょうか?通常のデータと何が違うのでしょうか。そんなおはなしをしていきます。

最近はStream型として有名になってきたかもしれませんね。1つの具体例にすぎませんが。

Codataを表現する

構造の型が S であるとします。S が 2つの \mathtt{int} 型を丁度持つとしたとき、これを S = \mathtt{int} \times \mathtt{int} と表記します。要は掛け算記号を使うということです。

これによって、今までの例では t の型は S = S \times \mathtt{int} \times S と表せます。(ポインタにNULLが入らないものとします)
また、a の方は S = \mathtt{int} \times S となることがわかります。(同様にundefined,nullでないものとします)

これを一般化して、S = F(S) の形式としてCodataを表します。つまり F を指定すればいいわけです。
無限二分木のときは、 F(X) = X \times \mathtt{int} \times X となります。
無限リストでは F(X) = \mathtt{int} \times X ですね。

これによって再帰構造を S = F(S) の部分だけに押し込めることが出来たので、扱いやすくなりました。

Coiteration : 余反復

Coiterationとは、Codataを作り出す方法の一つです。先程は「全ての要素が同じ値を持つ構造」しか作れませんでしたが、勿論そんなことはありません。

まぁ上で例示した方法ではどちらも出来ないですけど、それは本質ではありません。

一般的に考えてみましょう!

S を表現するためにはもちろん F(S) を表現できればいいわけで、F(S) というのは S の各要素のことです。つまり S の各要素を定めれば S が定まります。これは当然ですね。

 

ここで発想の転換をします : 「S の各要素で S を構成する」のではなく、「S から S の各要素を取り出す関数によって S を定義する」のです。

本質的に(抽象的に)、必要な構造は S から S の各要素を拾い出す関数たちです。
無限二分木の場合は \mathtt{left} : S \to S,\ \mathtt{value} : S \to \mathtt{int},\ \mathtt{right} : S \to S を与えれば良いわけです。
無限リストなら \mathtt{head} : S \to \mathtt{int},\ \mathtt{tail} : S \to S となります。

陽にデータを持つ必要はありません!これが重要なところです。全てのデータアクセスをこの関数群を通して行えば良いのですから、困ることはありません。

例えば \mathtt{head}(s) = 1,\ \mathtt{tail}(s) = s という関数群を考えると、先頭は明らかに 1、その次も 1 と、無限に 1 が続くリストを表現できていることがわかります。この調子です。

複数の関数をまとめてあげることで、無限二分木では \langle \mathtt{left, value, right} \rangle : S \to S \times \mathtt{int} \times S 、無限リストでは \langle \mathtt{head, tail} \rangle : S \to \mathtt{int} \times S があればいいということがわかります。
簡単に一般化できて、S \to F(S) と捉えることができます。これによって要素へのアクセスが可能となります。

 

さらに一般化して、任意の写像 X \to F(X)生成規則とみなしてみます。
集合 X に対して a : X \to F(X) を持っているとき、x \in X に対して a(x) \in F(X) を計算することができます。F(X)=X \times X などとしてみると a(x) = (p,q) となりますが、p,q それぞれに対して a を適用することで更に a(p),a(q)\in F(X) を獲得することができます。これは実質的に x がもともと a(p),a(q) を内部に情報として持っていたということと区別が付きません。

アクセサ a : X \to F(X) を備えた集合 X は、アクセサの働きによりCodataと同様の振る舞いをすることがわかります。
X \neq F(X) ではありますが、S = X \times (X \to F(X)) とすることで、S = F'(S) となるようなCodataを構成できるのです!

というわけで、これがCoiterationです。X \times (X \to F(X)) さえあればCodataを構成できるのです。


では例を考えてみましょう!とりあえずは無限リスト F(X) = \mathtt{int}\times X のことを考えます。

自明な例

X = \{*\},\ \mathtt{head}_X(*) = 1,\ \mathtt{tail}_X(*) = * とし、s \in S = X \times (X \to F(X)) として s = (*, \langle \mathtt{head}_X, \mathtt{tail}_X \rangle) すると、先程言ったように全ての要素が 1 の無限リストを表します。

自然数列

X = \mathtt{int},\ \mathtt{head}_X(n) = n,\ \mathtt{tail}_X(n) = n+1 とし、 s = (0,\langle \mathtt{head}_X, \mathtt{tail}_X\rangle) を考えると、

\mathtt{head}(s) =\, \mathtt{head}_X(0) = 0
\mathtt{tail}(s) =\, (\mathtt{tail}_X(0),\langle \mathtt{head}_X, \mathtt{tail}_X \rangle) = (1, \langle \mathtt{head}_X, \mathtt{tail}_X\rangle)
\mathtt{head}(\mathtt{tail}(s)) = \mathtt{head}((1, \langle \mathtt{head}_X, \mathtt{tail}_X\rangle)) =\, \mathtt{head}_X(1) = 1

となり、結果的に 0,1,2,3,4,\ldots という自然数の無限列を表すことが出来てることがわかります。

Fibonacci数列

X = \mathtt{int} \times \mathtt{int},\ \mathtt{head}_X((a,b)) = a,\ \mathtt{tail}_X((a,b)) = (b,a+b) とし、s = ((1,1),\langle \mathtt{head}_X, \mathtt{tail}_X\rangle) を考えると、

\mathtt{head}(s) = 1
\mathtt{head}(\mathtt{tail}(s)) = 1
\mathtt{head}(\mathtt{tail}(\mathtt{tail}(s))) = 2
\mathtt{head}(\mathtt{tail}(\mathtt{tail}(\mathtt{tail}(s)))) = 3
\mathtt{head}(\mathtt{tail}(\mathtt{tail}(\mathtt{tail}(\mathtt{tail}(s))))) = 5

と、Fibonacci数列を表していることがわかります。

Copattern : 余パターンマッチ

この無限リストにはどんな操作ができるでしょうか?
例えば先頭に新しく要素 v : \mathtt{int} を付け加える関数 \mathtt{cons}_v\ {:}\ S \to S が欲しかったり。
例えば全要素に関数 f : \mathtt{int} \to \mathtt{int} を適用する関数 \mathtt{map}_f\ {:}\ S \to S が欲しかったり。
例えば i \in \mathbb{N} 番目の要素を取り出す関数 \mathtt{index}_i\ {:}\ S \to \mathtt{int} が欲しかったりします。

ではまず、\mathtt{cons}_v は、どうやって定義すれば良いでしょうか?とりあえず私たちはCoiterationを組めば S を作れるので、試してみましょう。

X = ({0,1},\mathtt{int},S) とし、

\begin{array}{l}\mathtt{head}_X((0,v,s)) = v\\\mathtt{head}_X((1,v,s)) = \mathtt{head}(s)\\\mathtt{tail}_X((0,v,s)) = (1,v,s)\\\mathtt{tail}_X((1,v,s)) = (1,v,\mathtt{tail}(s))\end{array}

これから\mathtt{cons}_v(s) = ((0,v,s),\langle \mathtt{head}_X,\mathtt{tail}_X\rangle) と定義すれば、最初は v、後からは s が順番に出てくる無限リストを構成できます。

しかし、本質的なところはこれではないはずです。やりたいことはもっと単純なわけで、簡単に表現できていいはずなのです。

原義に立ち返ると、\langle \mathtt{head}, \mathtt{tail} \rangle が定義できればよかったはずなのです。逆に言えばこれらに対する全てのマッチングを書けば十分 S を定義したことになるはずです!

所謂パターンマッチは「データ構造の構成方法に関するマッチ」ですが、今回は「データ構造の分解方法に関するマッチ」なのです。これを余パターンマッチと呼び、マッチに使う「パターン」をCopattern (余パターン)と呼ぶわけなのです。

\begin{array}{l}\mathtt{head}(\mathtt{cons}_v(s)) = v\\\mathtt{tail}(\mathtt{cons}_v(s)) = s\end{array}

つまりこれで十分ではないでしょうか? 本来ならば関数の挙動を後から定義することなど出来ないはずですが、私達が扱っているのはDataではなくCodataなのです。これによって \mathtt{cons}_v(s) を十分定義することができるのです。

そしてこのように全ての分解方法に対して定義を示すことで関数を定義する方法をCoinduction(余帰納法)といいます。


余パターンマッチがあると様々な定義をわかりやすく書くことができます。

\mathtt{head}(s) = 1,\ \mathtt{tail}(s) = s は全ての要素が 1 の無限リストを表します。
\mathtt{head}(s) = 1,\ \mathtt{head}(\mathtt{tail}(s)) = 2,\ \mathtt{tail}(\mathtt{tail}(s)) = s とすると、1,2,1,2,\ldots という無限リストを表現できます。

\mathtt{head}(\mathtt{tail}(s)) = 2 などと書いて良いのでしょうか?良いのです!何故ならこれは(余)パターンマッチなのですから。
パターンで (x,(y,z)) = dat; と書くように、余パターンもネストしていいのです。

Corecursion : 余再帰

先程言った、全要素に f を適用する関数 \mathtt{map}_f も余パターンマッチで記述することができます。

\begin{array}{l}\mathtt{head}(\mathtt{map}_f(s)) = f(\mathtt{head}(s))\\\mathtt{tail}(\mathtt{map}_f(s)) = \mathtt{map}_f(\mathtt{tail(s))}\end{array}

確かに \mathtt{map}_f(s) を定義していそうですが、最後の \mathtt{map}_f(\mathtt{tail}(s)) が気になります。これは \mathtt{map}_f が再帰していることを表します。

これは正しく停止するのでしょうか? 再帰が停止しなければ定義できたとは言えません。

\mathtt{cons}_v を使って見慣れた再帰の形になおしても同じことです。

\begin{array}{l}\mathtt{map}_f(s) = \mathtt{cons}_{f(\mathtt{head}(s))}(\mathtt{map}_f(\mathtt{tail}(s)))\end{array}

再帰の停止に必要な基底段階の定義が無いのです。

しかし、まぁ、停止はせずとも定義は出来ているはずなのです。何故なら私達が扱っているのはCodataなので、再帰をしてデータ構造を作り出す過程を無限に行うことができます。実際にデータ構造の中身を見るには \mathtt{tail} などの関数を使わねばならず、実際 \mathtt{head}, \mathtt{tail} に対する挙動は明確に示されています。

このように、Codataの構造で再帰して新たなCodataを作る書き方をCorecursion(余再帰)といいます。無限に見える書き方にも関わらず、余再帰によって定まる関数は正しく定義されるのです。余再帰的定義から作り出される実際の関数のことをCofixpoint(余不動点)といいます。

なお、普通の再帰をCodata上で動かすこともできます。先程の \mathtt{index}_i は素直な再帰の例ですね。

\begin{array}{l}\mathtt{index}_0 (s) = \mathtt{head}(s) \\\mathtt{index}_{i+1} (s) = \mathtt{index}_i(\mathtt{tail}(s))\end{array}

ちょっと世界が広がった感じがしませんか。


余再帰のおもしろい例として、幅優先探索があります。深さ優先探索は再帰で素直に書けましたよね。幅優先探索は余再帰で素直に書けるのです。

(無限)二分木 t を幅優先探索することを考えます。普通なら「キューを作って」「一番上のノードを入れて」「子ノードをキューに入れる」というのを無限に繰り返していきます。

発想の転換です。全要素が既に入ったキューをもう持っているとするのです。キューの各要素は \mathtt{int} ではなく、Codata S です。

この全要素が既に入ったキューというのは勿論無限リストになるわけですから、CoiterationやCopatternで定義ができます。今回はCopatternを使いましょう。

\begin{array}{l}\mathtt{head}(\mathtt{flatten}(s)) = \mathtt{left}(\mathtt{head}(s))\\\mathtt{head}(\mathtt{tail}(\mathtt{flatten}(s))) = \mathtt{right}(\mathtt{head}(s))\\\mathtt{tail}(\mathtt{tail}(\mathtt{flatten}(s))) = \mathtt{flatten}(\mathtt{tail}(s))\\\\\mathtt{head}(\mathtt{bfs}(t)) = t \\\mathtt{tail}(\mathtt{bfs}(t)) = \mathtt{flatten}(\mathtt{bfs}(t))\end{array}

とりあえず具体的にどんな q = \mathtt{bfs}(t) が構成されたかを見てみましょう。

\begin{array}{l}\mathtt{index}_0(q) = \mathtt{head}(q) = t \\\mathtt{index}_1(q) = \mathtt{head}(\mathtt{flatten}(q)) = \mathtt{left}(\mathtt{head}(q)) = \mathtt{left}(t) \\\mathtt{index}_2(q) = \mathtt{head}(\mathtt{tail}(\mathtt{flatten}(q))) = \mathtt{right}(\mathtt{head}(q)) = \mathtt{right}(t) \\\mathtt{index}_3(q) = \mathtt{head}(\mathtt{tail}(\mathtt{tail}(\mathtt{flatten}(q)))) = \mathtt{head}(\mathtt{flatten}(\mathtt{flatten}(q))) = \mathtt{left}(\mathtt{left}(q)) \\\mathtt{index}_4(q) = \mathtt{right}(\mathtt{left}(q)) \\\mathtt{index}_5(q) = \mathtt{left}(\mathtt{right}(q)) \\\mathtt{index}_6(q) = \mathtt{right}(\mathtt{right}(q)) \\\mathtt{index}_7(q) = \mathtt{left}(\mathtt{left}(\mathtt{left}(q)))\end{array}

なんだか確かに幅探索していそうですね。考え方としては、まず \mathtt{head}t を入れておき、後続の余再帰で t を取り出した時にその \mathtt{left}\mathtt{right} を返すようにする、という感じです。結果、それらがキューに入り、また余再帰で各節点の \mathtt{left}\mathtt{right} がキューに入っていき、これを無限に繰り返します。

実際にプログラムとして動かすことも勿論出来ます。

無限二分木 t として次を考えます。(Stern-Brocot Tree)

一応Haskellのコードをおいておきます。

import Data.Ratio

data Tree a = Node (Tree a) a (Tree a)

cf :: Maybe Rational -> [Integer] -> Rational
cf (Just r) [] = r
cf (Just r) (x:xs) = cf (Just $ fromIntegral x + 1 / r) xs
cf Nothing  [] = 0
cf Nothing  (x:xs) = cf (Just $ fromIntegral x) xs

make :: Bool -> [Integer] -> Tree Rational
make b (x:xs) = Node (make True l) (cf Nothing $ x:xs) (make False r) where
  (l,r) = if b
    then (x+1 : xs, 2 : x-1 : xs)
    else (2 : x-1 : xs, x+1 : xs)

t :: Tree Rational
t = make False [1]

この木は各節点が \mathbb{Q} の要素を持っているので、F(X) = X \times \mathbb{Q} \times X で表せますね。

幅優先探索は次で定義されます。

bfs :: Tree a -> [Tree a]
bfs t = t : ext (bfs t) where
  ext ((Node l _ r):qs) = l : r : ext qs

あとは適当に値を抽出するようにするとこんな感じで動きます。ちなみにbfsをちょっと工夫すると、今何段目を探索中であるかなどの情報を付け加えることもできるようになります。

まとめ

Data では

  • Iteration によって畳み込みを行い
  • Pattern で要素に分解することによって Induction で関数を定義し
  • Algebra 上の Fixpoint を計算することで Recursion を行えるようにするのに対し

Codata では

  • Coiteration によって構築を行い
  • Copattern で要素を記述することによって Coinduction で関数を定義し
  • Coalgebra 上の Cofixpoint を計算することで Corecursion を行えるような体系でした。

普通のDataは分解することが主ですが、Codataでは構築することが主となります。

そしてまぁ、こういう Data←→Codata のような対応関係を 双対 といいます。帰納法と余帰納法、パターンマッチと余パターンマッチ、再帰と余再帰は双対なわけです。更に言うと幅優先探索は深さ優先探索の双対なのかもしれません。

今までのwell-knownなデータ構造から双対をうまくとることで新しい世界ができることもあるわけです。
Duality everywhere、です。

たのしくないですか?

おわりに

Codataの話だけでも意味のある内容だと思っていますが、実はもっと興味深い内容を含んでいるような気がします。一番最初に書いた、「データとは操作そのものではないか?」ということです。

一般的なプログラミング言語では、操作する対象は実際にメモリなどに存在しているビット列の成すデータでしょう。でもそれは本質ではないのです。
私達が本当にやりたかった処理はビット列を操作することではなくデータそのものを解釈し操作することのはずです。

実際の表現を知らずとも操作方法さえわかっていればデータを操作することができます。これが抽象化ということです。
Representation に関しては私達は関与する必要がなく、Interface と Syntax を重視すべきなのです。

だからもうC言語はダメなんです。あまりにも実表現に近すぎるのです。抽象化ができると主張したところで他の言語には勝てません。C言語で大きなシステムを組むという技術に最早意味はないのです。

 

抽象化というものを学ぶときに一番良いのは数学です。ちゃんとした数学というのは「具象を抽象に持ち上げる」という流れが必ず存在します。抽象の上での操作を具象に映すことで幅広い応用を可能にするのです。プログラミングの流れと大して変わりません。

具象がわからないと抽象を理解できないという話をよく聞きますが、それはそれで良いのです。重要なのは「抽象にたどり着くこと」であり、具象の段階で止まってしまうのはもったいないかなと思います。いろんなプログラミング言語は具象の1つであり、その共通構造というものを抽象化して認識することで全てのプログラミング言語が書けるようになる。理想化した話ではありますが、間違った話では無いはずです。

 

抽象化というものや、ものの本質などを考える数学の分野が圏論です。正確に言えば本質を捉えることで概念を統一したり、異なる分野の関連を調べる理論です。今回のCodataの話も圏論で議論することができます。興味があれば覗いてみるといいかもしれません。

参考文献

このエントリーをはてなブックマークに追加

コメントを残す

メールアドレスが公開されることはありません。