Julia言語で入門するプログラミング(その1)

はじめに

この記事は、プログラミングへの入門記事だ。初めてプログラミングをしてみようという人を対象にしている。非常に長い記事になりそうなので、何回かに分ける予定で、その第一回がこの記事となる。

使用するプログラミング言語としては、「Julia」を採用した。何らかのプログラミング言語の経験がある人も歓迎だ。Juliaは強力な言語なので、何か得られるものがあるはずだ。

題材としては、簡単なゲームの作成を行う。極めてシンプルなゲームからスタートして、徐々に機能を拡張させながら、Juliaの機能やソフトウェアの原則について学んでいく。まずはこの記事でプログラミングの基本を学び、その後により難しい問題に挑戦してもらいたい。

なぜJuliaか

Juliaという言語は、あまり名前を聞かない言語かもしれない。だがそれはJuliaが劣っているからではない。単にJuliaが若い言語だからだ。なぜJuliaを採用したか、理由はいくつかある。

Juliaはインストールが簡単だ

何と言ってもこれだ。独学者にとって、これはとても大事なことだ。入門者であれば尚更だ。環境構築でつまづくと何もかも嫌になってしまうものだ。Juliaはインストールが簡単なので、間違えようがないのだ。

Juliaは親しみやすい言語だ

Juliaは書いていて楽しい。とても楽しい。最小限の指示で、期待通りのことをこなしてくれる。快適さはあらゆる言語の中でもトップクラスだ。

Juliaは強力な言語だ

親しみやすいだけでなく、Juliaは非常に強力だ。贔屓目なしに、現存するプログラミングでも屈指の強力な機能を備えている。初心者向けのオモチャの言語ではないのだ。

Juliaはシンプルな言語だ

親しみやすく強力な言語でありながら、Juliaの提供している機能は驚くほどシンプルだ。余計なものが何一つなく、研ぎ澄まされている。

Juliaは高速な言語だ

ここまでのメリットがありながら、そのうえJuliaは高速に動作する。その速度はあらゆる言語の中でもトップクラスに位置する。親しみやすい言語は、しばしば速度が遅いのが欠点だ。これらは従来はトレードオフだと考えられてきたが、Juliaはそれを覆した。

要するに、Juliaにはデメリットらしきものが何もないのである。唯一の心配は、Juliaが快適すぎて他の言語を学ぼうという意欲が失せないかどうかということくらいだ。

なぜゲームか

題材としてゲームを取り上げている理由は、単純に面白いからだ。みんなゲームをするのは好きだよね?

プログラミングをすることで、自分の考えたゲームをデザインし、動かすことができるのだ。これはゲームをするのに劣らず楽しいな体験だ。

この記事で取り扱わない内容

今のうちに注意事項を述べておく。この入門コーナーでは、以下の要素は取り扱わない。

グラフィカルユーザーインターフェース

グラフィカルユーザーインターフェース(GUI)は取り扱わない。GUIとは、「アプリ」と言われてあなたが頭に思い浮かべたものだ。LineやらTwitterやらExcelやらのアプリを起動したら表示される、あの画面のことだ。もしくは、Webブラウザで動作するWebアプリを思い浮かべてもらっても良い。ユーザーがマウスクリックや画面タッチで操作し、アプリがそれに応答するという概念だ。

今や当たり前すぎて、名前がついているのが不思議なくらいな概念かもしれない。GUIは、一般ユーザーに受けいられるにはもはや必須と言える。

一方で、GUIのアプリケーションは難しい。GUIアプリケーション作成のためのツールは整えられているのだが、ツールを使いこなすのが難しい。入門者がいきなりGUIアプリケーションに取り組むと、2つのことを同時に学ぶ必要がある。プログラミング言語の学習と、ツールの学習だ。片方だけでも難しいのだ。いきなり両方はやめておいたほうがいい。そのようなわけで、まずはGUIアプリケーションのことは忘れよう。私と一緒にプログラミング言語を学ぼう。GUIアプリに取り組むのはそれからだって大丈夫だ。結局はそのほうが近道のはずだ。

科学技術計算

Juliaは科学技術計算向け言語として生まれたので、それを期待されるかもしれない。残念ながら、この記事では科学技術計算の例題は存在しない。

Julia言語の網羅的な解説

Juliaの機能についての網羅的な解説はしない。Juliaは公式ドキュメントが非常に充実しているので、必要であれば十分な情報が手に入る。ただ、公式ドキュメントは厳密であるがゆえに少し難しい用語が使われたりしているので、そういった用語を理解するための基礎知識を付けられるようにはしようと思う。

その他の注意事項

私は話が長い。キリリと引き締まった解説が読みたい人は、別の文章を読もう!

インストール

Juliaのインストールはとても簡単だ。公式サイトに行き、インストーラを落としてくるだけだ。

公式サイト

英語のサイトだからと言ってびっくりしないように。いや、びっくりしてもいいのだが、怖気付いて退散しないように。公式サイトにジャンプすると、現時点では左上にDownloadというボタンがある。下記の画像はv1.5.3とあるが、そこは変わっていくのであまり気にしなくていい。

f:id:muuuminsan:20201118000422p:plain
Julia公式サイトトップページ

Downloadボタンを押すと、相変わらず英語のページなのだが、少し下に移動すると、WindowsとかmacOSとか書いてある、OS別のインストーラが置いてあるページにたどり着くはずだ。自分のOSにあったものをダウンロードしよう。自分のOSがわからない人はいるかな?そんなあなたはまず間違いなくWindowsだろう。macLinuxなら好きで使っているはずだ。おそらく32bitか64bitかもわからないと思うので、「Windows ビット数 調べ方」で検索しよう。いい記事がたくさん出てくるはずだ。

さてダウンロードが終わったら、インストーラを起動してインストールしよう。選択肢を聞かれるかもしれないが、デフォルトでOKだ。もちろん、こだわりのある人は変えたらいいが、よくわからなければデフォルトでOKだ。困ったことにはならない。

他のサイトを参考にしている場合、パスを通したほうがいいとか、他のテキストエディタやツール類をインストールすべしとかいう情報を見るかもしれない。したければしたらいいが、よくわからなければ一旦忘れよう。必要であれば後でできる。困ったことにはならない。しても困ることもないのだが、最初に意味もわからず色々作業すると、何となく手の内に収まらない感じがして不安になってくるものだ。

Hello World

インストールは終わっただろうか?いよいよ、Juliaでのプログラミングを始めることができる。まずは、インストールされたJuliaアプリケーションを起動しよう。

最初に強調しておきたいのは、プログラミングは本や文章を追うだけではだめで、自分で手を動かすということが非常に重要であるということだ。できれば自分の手で打ち込んで欲しいし、コードをコピーしてもいいが、必ず少しは修正してあれこれ実験してみて欲しいと思う。

REPL

Juliaを起動すると、次のような画面が表示されたと思う。これがREPLと呼ばれるものだ。名前の由来はおいおい話すが、よく使われる単語なので覚えておこう。というか、この記事でもよく使うのですぐに覚えるだろう。

f:id:muuuminsan:20201118003937p:plain
JuliaのREPL

REPLではJuliaと「対話」することができる。REPLに次のように入力しよう。

julia> println("Hello World")

すると、Juliaが応答を返してくれるはずだ。

julia> println("Hello World")
Hello World

Hello World成功だ!あっけなかったかもしれない。そう感じたら、それだけ現代のソフトウェア開発環境が良くできているということだ。え?これって一体何なのかって?Hello Worldが何なのか気になるんだね?

その昔はコンピュータのセットアップは大仕事だった。何日もかけてハードウェアを組み立てる必要があっただろうし、ソフトウェアのインストールだって一筋縄ではないかない。試行錯誤末にようやく一台組み上げたのだ。苦労に苦労を重ねてついにコンピュータが外の世界とつながり、”Hello World”としゃべってくれたのだ。その喜びはひとしおだっただろう。赤ん坊がおぎゃあと産まれるようなものだ。母性か父性か、とにかく大きく崇高な感情に包まれたことだろう。ちなみに、完全に想像で語っている。

そのようなわけで、プログラミング言語の開発環境をセットアップしたら、とりあえず伝統的に最初にこれをやることになっているのだ。インストーラ一発で開発環境が構築できてしまう時代にHello Worldという文言は少し大袈裟な気もするが、まあ現代でも地鎮祭をやっているし、それと似たようなものだろう。(ちょっと違うかな?)

Juliaとの対話

ともかくあなたはHello Worldを成し遂げたわけだし、ついでにもう少しJuliaと対話してみよう。

簡単な四則計算

いろいろな計算式を入れてエンターキーを押すと、結果を喋ってくれる。

julia> 2 + 3
5

julia> 2 - 3
-1

julia> 2 * 3
6

julia> 2 / 3
0.6666666666666666

四則計算は完璧だ。ちなみに珍しいところで、次のような計算もできる。バックスラッシュ記号で、分子と分母が逆になるのだ。

julia> 2 \ 3
1.5

次の結果がどうなるかは、ぜひ自分の手でJuliaに聞いて欲しい。

julia> 2 / 3 \ 6

優先順位づけの括弧ももちろん使うことができる。

julia> (2 / 3) \ 6
9.0

julia> 2 / (3 \ 6)
1.0

"ans"とJuliaに言うと、直前の結果だと解釈してくれる。

julia> 1 + 1
2

julia> ans
2

julia> ans + 1
3

julia> ans * 2
6

ま、このくらいでいいだろう。ここまではまだ電卓と大差ない。我々はプログラミング言語をインストールしたのだ。もっと先へ進もう。次からいよいよプログラミング言語らしくなってくるのだ。もちろん、ここらで一休みするのもいい。

基本文法

これからJuliaの基本的な文法について説明する。目的は文法について詳細まで網羅することではない。まずは最低限のゲーム作成のために最低限必要な部分だけ話していく。ゲームを作り始めたら、その中で、基本文法の中でも説明しなかったような箇所や、高度な文法にも徐々に触れていく。

変数

変数という言葉は読んで字の如く「変化する数」ということだ。プログラミング言語では、変数という仕組みで数に名前をつけることができる。たとえば、次の例は、xという名前の変数を作り、そこに入れた値を色々と変化させている。一度Juliaに変数を教えると、変数の値を覚えておいてくれるのだ。

まずJuliaにx = 1と教える。ここで重要なのは、イコール記号は数学のように「xは1に等しい」と宣言したわけではなく、あくまで「xという名前の変数に1という値を設定する」という意味だということだ。

julia> x = 1
1

次にJuliaにxってなに?と聞く。すると1と答えてくれる。

julia> x
1

次にJuliaにx = 2と教える。繰り返すが、イコール記号は数学のように「xが何かに等しい」と宣言したわけではないので、先ほどのx = 1とは矛盾しない。あくまで「xという名前の変数に2を設定する」という意味だ

julia> x = 2
2

そしてJuliaにxってなに?と聞く。すると2と答えてくれる。

julia> x
2

次にJuliaにx + 1ってなに?と聞く。すると賢いJuliaはxが2だったことを覚えてくれていて、3と答えてくれる。そして、xは相変わらず2だ。

julia> x + 1
3

julia> x
2

次にJuliaにx = x + 1と教える。しつこいようだが、イコール記号は数学のように「x が x + 1 と等しい」と宣言したわけではない。あくまで「xという名前の変数に、x + 1(=3)という値を設定する」という意味だ。

julia> x = x + 1
3

そしてJuliaにxってなに?と聞く。Juliaは3と答える。

julia> x
3

だんだんプログラミングっぽくなってきただろう。さて、x = 1は、「xという名前の変数に1という値を設定する」と表現したが、同じことを差して、「xに1を代入する」という言い方をすることもある。というか、この方が主流だ。

ところで、Juliaの特徴に、変数はアルファベットや数字に限らないというものがある。多くの言語は、変数に使えるのはアルファベットと数字と一部の記号くらいのものだが、Juliaはギリシャ文字やひらがな、漢字も使える。だから、こんなこともできる。

julia> いち = 1
1

julia> に = 2
2

julia> いち + に
3

これから先は、日本語による変数名など必要に応じて積極的に使っていく。便利な機能は何だって使っていこう。

文字列

変数について話したので、文字と文字列について話しておこう。文字とは文字のことであり、文字列とは列をなした文字のことである。人を馬鹿にしているのかというような説明なので、Hello Worldプログラムを思い出そう。

julia> println("Hello World")
Hello World

""で囲まれた部分が文字列だ。そして、それら一つ一つを構成するのが文字である。

文字を作るにはシングルクオーテーションで囲う。この場合、xという文字そのものを作っている。

julia> 'x'
'x': ASCII/Unicode U+0078 (category Ll: Letter, lowercase)

2つ以上の文字をシングルクオーテーションで囲うとエラーになる。

julia> 'xy'
ERROR: syntax: character literal contains multiple characters

2つ以上の文字を表現したいときには、ダブルクオーテーションで囲む。そうすると、文字列となる。

julia> "xy"
"xy"

先ほど、変数はxのように文字で宣言したのだった。しかし、これは文字'x'や、文字列"x"とは違う。

変数xには値を代入することができるが、文字'x'や文字列"x"には値を代入できない。

julia> x = 1
1

julia> 'x' = 1
ERROR: syntax: invalid assignment location "Char(0x78000000)" around REPL[2]:1

julia> "x" = 1
ERROR: syntax: invalid assignment location ""x"" around REPL[3]:1

こういったエラーメッセージが出てきたとき、怖くなって画面を閉じてはいけない。エラーメッセージをきちんと読んで理解しようとする姿勢があるかないかが勝敗を分ける。英語だからといって思考停止してはいけない。最近は良い翻訳ソフトもあるのだ。現時点ではDeepL翻訳がおすすめだ。

試しに下の方のメッセージをDeepL翻訳にかけると、「ERROR: 構文: REPL[3]:1 の周りの ""x"" の代入場所が無効です。」となる。代入をしようとして怒られたことがわかるだろう。文字や文字列は、代入の右辺に来る存在だ。下記の例では変数aに文字'x'や文字列"x"を代入している。

julia> a = 'x'
'x': ASCII/Unicode U+0078 (category Ll: Letter, lowercase)

julia> a
'x': ASCII/Unicode U+0078 (category Ll: Letter, lowercase)

julia> a = "x"
"x"

julia> a
"x"

文字を連結して文字列にすることができる。文字や文字列の連結には、*記号を使う

julia> 'a' * 'b'
"ab"

julia> "x" * "y"
"xy"

julia> "xy" * 'z'
"xyz"

文字列の何番目かを指定して文字を取得することもできる。指定するには[]で囲んだ数字を使う。普通のプログラミング言語と違い、指定する数字は1から始まる。0番目というのは不正な指定でエラーである。

julia> "abc"[0]
ERROR: BoundsError: attempt to access String
  at index [0]

julia> "abc"[1]
'a': ASCII/Unicode U+0061 (category Ll: Letter, lowercase)

julia> "abc"[2]
'b': ASCII/Unicode U+0062 (category Ll: Letter, lowercase)

julia> "abc"[3]
'c': ASCII/Unicode U+0063 (category Ll: Letter, lowercase)

配列

文字を並べたら文字列になるのだ。数字を並べたらどうなるのだろうか?これは配列と呼ばれるものになる。数字を単にずらずらと並べると、桁数の多い数字になるだけなので、区切り文字にカンマを使う。

julia> [10, 20, 30]
3-element Array{Int64,1}:
 10
 20
 30

配列の何番目かを指定して数字を取得することもできる。Juliaでは、配列も文字列と同じく、何番目かの指定は1から始まる数字で行う。

julia> [10, 20, 30][1]
10

実際には、配列の中身には、数値だけではなくて文字や文字列も入れられる。

julia> ['a', 'b', 'c']
3-element Array{Char,1}:
 'a': ASCII/Unicode U+0061 (category Ll: Letter, lowercase)
 'b': ASCII/Unicode U+0062 (category Ll: Letter, lowercase)
 'c': ASCII/Unicode U+0063 (category Ll: Letter, lowercase)

julia> ["abc", "def", "ghi"]
3-element Array{String,1}:
 "abc"
 "def"
 "ghi"

最初に、文字を並べたら文字列になり、数値を並べると配列になると言ったが、文字の配列が文字列になるわけではない。明確に別のものだ。

julia> "abc"
"abc"

julia> ['a', 'b', 'c']
3-element Array{Char,1}:
 'a': ASCII/Unicode U+0061 (category Ll: Letter, lowercase)
 'b': ASCII/Unicode U+0062 (category Ll: Letter, lowercase)
 'c': ASCII/Unicode U+0063 (category Ll: Letter, lowercase)

ただ、よく似た動作をすることもある。

julia> "abc"[1]
'a': ASCII/Unicode U+0061 (category Ll: Letter, lowercase)

julia> ['a', 'b', 'c'][1]
'a': ASCII/Unicode U+0061 (category Ll: Letter, lowercase)

数値と文字列を混在させたような配列も作れる。

julia> [2, 2.9, "abc"]
3-element Array{Any,1}:
 2
 2.9
  "abc"

さてさてここまではどうだっただろうか?楽しかっただろうか?多分楽しくなかったんじゃないだろうか?一体、いつになったらプログラミングが始まるんだよ!そう思った人が多いんじゃないだろうか?

お待たせしてしまい申し訳ない。そんな辛い辛い日々もこれで終わりだ。いよいよ次からはプログラミングらしいことをしていこう。次から説明するfor文と関数、これだけ説明したらゲームプログラミングの世界に飛び込んでいく。なお、変数名など、日本語が使えるところには日本語を使っていく。他言語を使ったことのある人は違和感があるかもしれないが、私はわかりやすくていいと思う。便利な機能は何だって使っていこう。

for文

そろそろゲームっぽい例も出せるくらいの地点まできた。連続攻撃をしてくる敵がいるとしよう。連続攻撃で与えられるダメージを合計した値を知りたい。そのようなときにどのようなプログラムを書くのだろうか?

まず、連続攻撃のダメージが配列に入っているとしよう。[10, 12, 11, 18] だ。これの合計を知りたい。

まずは変数の定義だ。ダメージの合計値はダメージ合計という変数に、連続攻撃によるダメージの配列は連続ダメージという変数に代入する。

julia> ダメージ合計 = 0
0

julia> 連続ダメージ = [10, 12, 11, 18]
4-element Array{Int64,1}:
 10
 12
 11
 18

そして、連続ダメージの合計を行うために、繰り返し、ダメージ合計の変数に連続ダメージの中身を足していく処理が、本節の主役のfor文である。

julia> for ダメージ in 連続ダメージ
         ダメージ合計 = ダメージ合計 + ダメージ
       end

forは処理を繰り返し実行する。forの行からendの行までの間がfor文の本体だ。今回であればダメージ合計 = ダメージ合計 + ダメージの1行だけだ。この処理を繰り返し実行する。繰り返し処理のことをループ処理と呼ぶこともある。for文はforループと呼ばれることもある。

何回繰り返しを行うかを決めているのが、in の後の要素だ。for文は、inの後の要素を一つずつ取り出しながら、本体の処理を実行する。

今回はinの後に連続ダメージが設定されている。連続ダメージの先頭から順番に値を取り出し、ダメージという名前をつけた変数に代入している。そして、ダメージ変数の値をダメージ合計変数に加算していっている。

細かくみていこう。まず最初の要素は10である、そのため、ダメージには10が入る。その状態で、ダメージ合計 = ダメージ合計 + ダメージの計算処理が動く。ダメージ合計は最初は0に設定しているので、その後 ダメージ合計 = 0 + 10で、ダメージ合計は10である。

次の要素12がダメージに代入される。その状態で、ダメージ合計 = ダメージ合計 + ダメージの計算処理が動く。ダメージ合計は先ほど10になったので、 ダメージ合計 = 10 + 12で、ダメージ合計は22である。

次の要素11がダメージに代入される。その状態で、ダメージ合計 = ダメージ合計 + ダメージの計算処理が動く。ダメージ合計は先ほど22になったので、 ダメージ合計 = 22 + 11で、ダメージ合計は33である。

最後の要素18が``ダメージに代入される。その状態で、ダメージ合計 = ダメージ合計 + ダメージの計算処理が動く。ダメージ合計は先ほど33になったので、ダメージ合計 = 33 + 18で、ダメージ合計```は51である。

すべてが終わったら、連続ダメージの合計値がダメージ合計変数に入っているだろう。

julia> println(ダメージ合計)
51

for文はあらゆる言語に登場すると言ってもいいほどの超重要構文である。文章で読むだけだとわかりづらいかもしれないが、動かすとすぐに慣れるだろう。for文と、この後紹介するif文をマスターしたら、かなりのプログラムが書けるようになる。

練習問題
  • 問題1

    • for文を使って、1から10までの数字の合計を求めてみよう。合計は55になるはずだ。
  • 問題2

    • 範囲オブジェクトというものを使ってみよう。1から10までの数字は、[1, 2, 3, 4, 5, 6, 7, 8, 9, 10] と書く以外にも、1:10と書くやり方もある。これを範囲オブジェクトと呼ぶ。問題1のループを範囲オブジェクトで書き直してみよう。
  • 問題3

    • 範囲オブジェクトをもう少し深掘りしよう。1から10までの数字を1つ飛ばしで合計する処理を範囲オブジェクトを使って書いてみよう。つまり、[1, 3, 5, 7, 9]を合計した結果と同じになるようにしてみよう。1つ飛ばしの範囲オブジェクトは説明していないので、何かを検索してみる必要があるだろう。

関数

for文という本格的な構文も身についたので、「関数」というものを紹介しよう。

先ほどのfor文で、配列の要素の値をすべて合計するという処理を書いた。題材としては、連続攻撃を受けた際の合計ダメージを計算する処理だったが、他の題材にも使える例であることは明らかだろう。例えば、何体かのモンスターを倒したとして、その合計の経験値を計算するというような例だ。

このようなとき、連続ダメージの計算の処理、経験値の計算処理のそれぞれで、同じような処理を書くことになる。

ダメージ計算の処理を再掲する。Juliaの応答は省略している。

julia> ダメージ合計 = 0
julia> 連続ダメージ = [10, 12, 11, 18]
julia> for ダメージ in 連続ダメージ
         ダメージ合計 = ダメージ合計 + ダメージ
       end

経験値の計算処理は次のようになるだろう。

julia> 経験値合計 = 0
julia> 経験値リスト = [22, 25, 14, 30, 44]
julia> for 経験値 in 経験値リスト
         経験値合計 = 経験値合計 + 経験値
       end

実行している処理の大枠は同じで、変数などが別なだけだ。これは面倒だ。配列の合計をするという処理が同じであれば、そのような処理をただ1つだけ書き、連続ダメージの計算の処理、経験値の計算処理のそれぞれで、その処理を共有するような仕組みが欲しい。このような処理を共通化する仕組みを「関数」と呼ぶ。

関数は次のようにして作ることができる。

julia> function 合計(配列)
         合計値 = 0
         for 要素 in 配列
           合計値 = 合計値 + 要素
         end
         return 合計値
       end
合計 (generic function with 1 method)

使うときにはこう使う。

julia> 連続ダメージ = [10, 12, 11, 18]
julia> ダメージ合計 = 合計(連続ダメージ)
julia> 経験値リスト = [22, 25, 14, 30, 44]
julia> 経験値合計 = 合計(経験値リスト)

合計という共通処理を、ダメージ計算、経験値計算で活用しているのがわかるだろう。

関数の使い方は非常に重要なので、しっかりと解説していく。

まず、関数を作りたいと思ったら、functionと書く。functionというのは関数を意味する英語だ。

function 

次に、どのような名前で呼びたいかを決める。今回の場合合計だ。これは好き勝手に決めればよく、「配列合計」「配列の合計」「合計値の計算」等々でも良い。

function 合計

次に、その処理がどのような入力を受け取るかを決める。入力は、関数名の後の括弧で囲まれた部分だ。さらに、入力は1つではなく複数になってもいい。その場合は、間まで区切る。今回は、入力が一つだけなので括弧はなく、その唯一の入力に「配列」という名前をつけている。入力の名前も好き勝手に決めて良い。

function 合計(配列)

その後は関数で行う処理本体を書いていく。

function 合計(配列)
  合計値 = 0
  for 要素 in 配列
    合計値 = 合計値 + 要素
  end

最後に、関数の処理結果を宣言するために、returnと書き、続けて何を結果とするかを書く。今回のケースだと、合計値を結果とする。最後に関数の終了を明示するために、endと書く。

function 合計(配列)
  合計値 = 0
  for 要素 in 配列
    合計値 = 合計値 + 要素
  end
  return 合計値
end

これで完成だ!難しく思えたかもしれないが、言葉で書くと冗長なだけだ。実際にはそう難しくはない。自分で何か関数を作ってみよう。

関数の定義について、形式的に書くとこうなる。

function 関数名(仮引数1, 仮引数2, ...)
  (処理本体)
end

別に、この形を覚えて欲しいわけではない。ただ、重要な用語として、「仮引数」というものは覚えて欲しいから紹介した。関数の入力を引数(ひきすう)と呼ぶ。そして、関数の定義に書かれる引数のことを「仮引数」と呼ぶのだ。関数の定義の時に、仮につける名前の引数だから仮引数だ。実際に関数呼び出しの時に与えられる引数のことを、実引数と呼ぶ。まあ、単に引数ということも多い。そんな時は文脈から判断できるはずだ。

この例では処理の共通化のために関数を作るとしているが、実際には1回しか呼ばれない処理でも関数化されることはよくある。処理の意味を明確化して名前をつけるということが目的だ。

練習問題
  • 問題1

    • 2つの引数を取り、その合計値を返す関数を作ってみよう。
  • 問題2

    • 2つの引数を取り、その平均値を返す関数を作ってみよう。問題1で作った関数を呼び出すこと。

Visual Studio Codeのインストール

長い長い間、よく我慢してもらった。ついにゲームを作っていこう。

しかし、その前に追加のツールをインストールしたい。

これまではREPLに直接コードを記述してきたが、REPLを閉じると消えてしまうし、一度作った関数を手直ししたい時も面倒だ。打ち込んだコードをファイルに保存したい。

ファイルに保存するだけであれば何か適当なテキストエディタにコードを書いて、「.jl」という拡張子で保存したら良い。だから、Windowsなら「メモ帳」、macなら「テキストエディット」でも良いのだが、やはりここはプログラミングに向いたエディタを使うべきだ。プログラミングに向いたのエディタであれば、色々と気の利いたことをやってくれる。

ここでお勧めするのが、Microsoft社によるテキストエディタVisual Studio Code」だ。無料で、どのOSでも使うことができる、軽量で拡張性の高いテキストエディタだ。エディタだと言いながら主要言語なら実行やデバッガによるステップ実行まですることができる怪物エディタだ。思想・信条上の理由がある人以外はぜひVisual Studio CodeでJuliaコードを書くべきだ。

Visual Studio Code公式サイトからダウンロードできる。ダウンロードしてインストールしたら、起動してみよう。この後する必要があるのは1つだけ。拡張機能のインストールだ。

f:id:muuuminsan:20201121153014p:plain
Visual Studio CodeへのJulia拡張機能のインストール

  1. 一番左に、四角形が4つ並んだ(1つは外れた位置にある)アイコンがあるので、そこをクリックする。
  2. 検索バーが出てくるので、juliaと入力する。
  3. おそらく一番先頭に、Juliaというシンプルなものが出てくるので、それをインストールしよう。

これでOKだ。これで準備完了だ。ゲームを作っていこう。

これからつくるゲームについて

これから作るのは極シンプルなRPGゲームだ。プレイヤーは味方キャラクターを操って、モンスターを倒していく。ひとまずプレイヤーは一人、モンスターも一体という単純なゲームだ。最初は驚くほどつまらないゲームだが、徐々に機能を追加していく。その過程で徐々に高度なプログラミングも学んでいこうという目論見だ。

最初に作るゲーム

最初に作るのは、勇者が無抵抗なモンスターをボコボコに攻撃するのをただ鑑賞するだけのゲームだ。ドン引きだ。下手したらR指定だ。何とも悪趣味なゲームだが、今作れるのはこれだけなのでしょうがない。

Visual Studio Codeで、新しいファイルを作って、適当な名前("rpg.jl"など)で保存しよう。保存先のフォルダは適当でいい。それが終わったら、次のようなコードを書こう。

function main()
    モンスターHP = 30
    プレイヤー攻撃力 = 10

    println("モンスターに遭遇した!")
    println("戦闘開始!")

    for _ in 1:3
        println("----------")
        println("勇者の攻撃!")
        モンスターダメージ = プレイヤー攻撃力
        モンスターHP = モンスターHP - モンスターダメージ
        println("モンスターは" * string(モンスターダメージ) * "のダメージを受けた!")
        println("モンスターの残りHP:" * string(モンスターHP))
    end

    println("戦闘に勝利した!")
end

main()

このコードは、大きく2種類の部分に大別される。1つはmainという関数の定義であり、もう1つはmain関数の呼び出しだ。

呼び出しの部分は大したことはないので、main関数の中身に注目しよう。main関数の中身では、実際に行われる戦闘の詳細が記述されている。HPというのはヒット・ポイントの略で、どのくらい攻撃に耐えられるかの略称である。攻撃力とは文字通り、相手を攻撃した時にHPをいかに減少させられるかの値である。この例では、30のHPのモンスターに、10の攻撃力で攻撃しているので1回あたり10のHPを減らすことになる。これを3回繰り返したら相手のHPは0になり、ゲームは終了する。

最初にモンスターのHPと勇者の攻撃力を設定しているのは以下の部分だ。

モンスターHP = 30
プレイヤー攻撃力 = 10

そして、初期設定が終わったらバトル開幕の合図だ。

println("モンスターに遭遇した!")
println("戦闘開始!")

その後は3回攻撃している。forの後が_になっている。

for _ in 1:3
    println("----------")
    println("勇者の攻撃!")
    モンスターダメージ = プレイヤー攻撃力
    モンスターHP = モンスターHP - モンスターダメージ
    println("モンスターは" * string(モンスターダメージ) * "のダメージを受けた!")
    println("モンスターの残りHP:" * string(モンスターHP))
end

普通であれば、for i in 1:3のように、繰り返しの何回目かを格納する変数名をつける。しかし、今回のケースであれば3回繰り返すことが大事であり、何回目の繰り返しかは重要ではない。その表明としてアンダーバーを用いている。iから_に変えたところで、コンピュータの実行上の違いはない。しかし、このコードを読む人間に対しては、繰り返しの何回目かは関係ないという表明になっている。これは私がそのほうがいいと思ってやっているだけなので、普通にfor i in 1:3としてもらっても全く構わない。

最後に、勝利を宣言している。

println("戦闘に勝利した!")

このコードを実行しよう。

モンスターに遭遇した!
戦闘開始!
----------
勇者の攻撃!
モンスターは10のダメージを受けた!
モンスターの残りHP:20
----------
勇者の攻撃!
モンスターは10のダメージを受けた!
モンスターの残りHP:10
----------
勇者の攻撃!
モンスターは10のダメージを受けた!
モンスターの残りHP:0
戦闘に勝利した!

勝ったには勝ったが、虚しい。これではあまりに不公平だ。モンスターは攻撃する機会すら与えられていない。あなたはこのような不公平さを良しとする人物ではないはずだ。次に進むとしよう。

自動テストとリファクタリング

次に進みたいところで、横槍が入った。このアプリケーションの仕様を聞きつけた役員の一人から、防御力も考慮すべしという意見が出てきたのだ。

どうやらその役員はペットの亀を溺愛しているらしく、頑丈な甲羅を持つ亀が、その他のひよわな獣と同等に扱われるのが我慢ならないということである。

問題の箇所はここである。

モンスターダメージ = プレイヤー攻撃力

受けるダメージが攻撃力に等しいということを暗黙の前提としているのだが、それはおかしいという意見である。確かに、硬い敵と柔らかい敵というのは存在する。そのため、防御力を考慮したダメージ計算とすべしという話である。

あいわかりました、と防御力を考慮した式にするのは容易い。しかし、安易に変更してはならない。もう一度言う。安易に変更してはならない。

我々は、人間だ。人間というものは不完全なものだ。だから、何かを変更する時には、必ず何かミスをすると考えるべきなのだ。あなたが東大卒であろうが、弁護士だろうが、全国模試1位の高校生だろうが、ノーベル賞受賞者だろうが関係ない。人間である以上、必ずミスをする。いつか必ず絶対にミスをする。それが今日この場ではないと誰が保証できるだろうか。少なくともあなた自身で保証できないのは確かだ。

だから我々には、自動テストが必要だ。あなたの行った変更が、「期待通り」であることを保証してくれる自動テストが必要だ。「何も壊していない」ことを保証してくれる自動テストが必要だ。

自動テストだ。自動テストだ。自動テストだ。自動テストだ。あなたが実家の倉庫を整理していると、古びたランプが出てきたとしよう。試しに擦ってみると、ランプの魔神が現れた。ランプの魔神はあなたの願いを3つ叶えてくれるという。2つはあなたの好きにしたら良い。1000兆円の現金だろうが、生き別れの弟との再会だろうが、それはあなたの望みのままだ。だが、最後の1つは自動テストの作成にして欲しい。

自動テストとは何だろうか?それは、自動化されたテストのことである。普通のテストは人間がプログラムを動かして行う。一方、自動テストは、プログラムを動かすプログラムを作る。「このプログラムを動かすとこうなる」という入力と出力の組み合わせを事前に定めておき、変更が入るたびに実行し、結果がわかるようにしておくのだ。

変更の詳細

それでは本題に入ろう。

まず、変更の要望は、防御力というパラメータを設定して欲しい、受けるダメージは防御力を高くすると減らして欲しいと言うものだ。もう少し詳細に確認すると、ダメージ = 10 * 攻撃力 / 防御力にして欲しいと言うことらしい。防御力が平均的なモンスターの場合は防御力10に設定される。これであれば、防御力を考慮しつつ、平均的な防御力のモンスターに関しては、既存のゲームバランスを見直す必要がない。

変更を素直に実装するとこうなる。

function main()
    モンスターHP = 30
    モンスター防御力 = 10 #追加行
    プレイヤー攻撃力 = 10

    println("モンスターに遭遇した!")
    println("戦闘開始!")

    for _ in 1:3
        println("----------")
        println("勇者の攻撃!")
        モンスターダメージ = round(Int, 10 * プレイヤー攻撃力 / モンスター防御力) #変更行
        モンスターHP = モンスターHP - モンスターダメージ
        println("モンスターは" * string(モンスターダメージ) * "のダメージを受けた!")
        println("モンスターの残りHP:" * string(モンスターHP))
    end

    println("戦闘に勝利した!")
end

roundというのは四捨五入だ。ダメージは整数にしたい。Roundの第1引数のIntというのは、四捨五入した後に、Int、すなわち整数に変換するようにということだ。Intについてはまだ解説していないので、そんなものかとスルーして欲しい。

大事なのは、いきなりこのようなことをすべきではないということだ。

次のような戦略で向かおう。

  1. ダメージ計算を関数化する。新旧の値を自動テストで比較しながら、関数化したものに置き換える。
  2. 関数化したダメージ計算を、あるべきダメージ計算に修正する。

1. ダメージ計算を関数化する

まずは、モンスターダメージ = プレイヤー攻撃力の部分の関数化だ。「変更前の動き」と「関数呼び出しに置き換えた結果」が同じであることを保証する自動テストを作成する。自動テストが通れば、関数呼び出しに置き換えて良い。

自動テストを作成するために、プログラムの先頭に、using Testと書いておく。別に先頭でなくて、自動テストのコードが書かれる直前でも良いのだが、こだわる部分でもないので先頭に書いておく。

次に、自動テストを設定する箇所を包むように、@testset begin から endで括っておく。これは、この中にテストコードが含まれていますよという宣言で、テストコードを含んでいない今の時点では、まだ何も動作を変えない。

using Test #追加行

function main()
    モンスターHP = 30
    プレイヤー攻撃力 = 10

    println("モンスターに遭遇した!")
    println("戦闘開始!")
    @testset begin #追加行
        for _ in 1:3
            println("----------")
            println("勇者の攻撃!")
            モンスターダメージ = プレイヤー攻撃力
            モンスターHP = モンスターHP - モンスターダメージ
            println("モンスターは" * string(モンスターダメージ) * "のダメージを受けた!")
            println("モンスターの残りHP:" * string(モンスターHP))
        end #追加行
    end

    println("戦闘に勝利した!")
end

main()

次に、関数呼び出しに変更する箇所にテストコードを入れる。==というのは、両辺が等しいという意味で、この場合、ダメージ計算という関数を作っても、とりあえず以前と同じ結果になっているということを表明している。

using Test

function main()
  (略)
        @testset begin
            (略)
            モンスターダメージ = プレイヤー攻撃力
            @test プレイヤー攻撃力 == ダメージ計算(プレイヤー攻撃力)            
            モンスターHP = モンスターHP - モンスターダメージ
            (略)
        end
  (略)
end

main()

こうしてプログラムを実行すると、次のようになると思う。

モンスターに遭遇した!
戦闘開始!
----------
勇者の攻撃!
test set: Error During Test at 〜
  Test threw exception
  Expression: プレイヤー攻撃力 == ダメージ計算(プレイヤー攻撃力)
  UndefVarError: ダメージ計算 not defined

ダメージ計算なんて関数は無いよと言われているのだ。そのとおりだ。作ってあげよう。

using Test

function ダメージ計算(攻撃力)
    return 攻撃力
end

function main()
  (略)
end

main()

こうすると次のような結果になるはずだ。

ンスターに遭遇した!
戦闘開始!
----------
勇者の攻撃!
モンスターは10のダメージを受けた!
モンスターの残りHP:20
----------
勇者の攻撃!
モンスターは10のダメージを受けた!
モンスターの残りHP:10
----------
勇者の攻撃!
モンスターは10のダメージを受けた!
モンスターの残りHP:0
Test Summary: | Pass  Total
test set      |    3      3
戦闘に勝利した!

下の方にテストの結果が表示されている。見ると、テストの失敗は発生していない。つまり、安心して関数呼び出しに置き換えられるということだ。置き換えたのが次の形だ。

using Test

function main()
  (略)
        @testset begin
            (略)
            モンスターダメージ = ダメージ計算(プレイヤー攻撃力) #ここを変更した
            @test プレイヤー攻撃力 == ダメージ計算(プレイヤー攻撃力)            
            モンスターHP = モンスターHP - モンスターダメージ
            (略)
        end
  (略)
end

main()

2. 関数化したダメージ計算を修正する

次は、このダメージ計算関数の変更だ。ここでモンスターの防御力を考慮した式に変更する必要がある。

function ダメージ計算(攻撃力, 防御力)
    return round(Int, 10 * 攻撃力/防御力)
end

しかしこれも、一目散に置き換えてはいけない。テストを作って安全に置き換えるのだ。

次にすべきことは何かというと、既存の関数の修正ではなく、新しい関数を作るということだ。名前は何でも良いので、ダメージ計算2としよう。

function ダメージ計算(攻撃力)
    return 攻撃力
end

function ダメージ計算2(攻撃力, 防御力)
    return round(Int, 10 * 攻撃力/防御力)
end

呼び出し箇所はまだ何も変えない。

function main()
  (略)
        @testset begin
            (略)
            モンスターダメージ = ダメージ計算(プレイヤー攻撃力)
            @test プレイヤー攻撃力 == ダメージ計算(プレイヤー攻撃力)
            (略)
        end
  (略)
end

main()

実行すると、テストは通る。関数を追加しただけで呼び出し元を変更したわけではないので当たり前だ。

次は、テストコードを変更する。ダメージ計算ダメージ計算2で置き換えられるかというテストだ。

function main()
  (略)
        @testset begin
            (略)
            モンスターダメージ = ダメージ計算(プレイヤー攻撃力)
            @test ダメージ計算(プレイヤー攻撃力) == ダメージ計算2(プレイヤー攻撃力, モンスター防御力)
            (略)
        end
  (略)
end

main()

これは、エラーになる。これは期待通りだ。なぜなら、モンスター防御力を定義していないからだ。

----------
勇者の攻撃!
test set: Error During Test at 〜rpg1.jl:24
  Test threw exception
  Expression: ダメージ計算(プレイヤー攻撃力) == ダメージ計算2(プレイヤー攻撃力, モンスター防御力)
  UndefVarError: モンスター防御力 not defined
  Stacktrace:
   [1] macro expansion at /Users/kenji/Documents/SourceCode/rpg/rpg1.jl:24 [inlined]
   [2] macro expansion at /Users/julia/buildbot/worker/package_macos64/build/usr/share/julia/stdlib/v1.5/Test/src/Test.jl:1115 [inlined]
   [3] main() at /Users/kenji/Documents/SourceCode/rpg/rpg1.jl:20
  
モンスターは10のダメージを受けた!
モンスターの残りHP:0
Test Summary: | Error  Total
test set      |     3      3
ERROR: LoadError: Some tests did not pass: 0 passed, 0 failed, 3 errored, 0 broken.
in expression starting at 〜rpg1.jl:34

というわけで、モンスター防御力を定義する。

function main()
    モンスターHP = 30
    モンスター防御力 = 10 #追加行
    プレイヤー攻撃力 = 10

    (略)
    
    @testset begin
        for _ in 1:3
            (略)
            モンスターダメージ = ダメージ計算(プレイヤー攻撃力)
            @test ダメージ計算(プレイヤー攻撃力) == ダメージ計算2(プレイヤー攻撃力, モンスター防御力)
            (略)
        end
    end

    (略)
end

すると、テストが通った!

モンスターに遭遇した!
戦闘開始!
----------
勇者の攻撃!
モンスターは10のダメージを受けた!
モンスターの残りHP:20
----------
勇者の攻撃!
モンスターは10のダメージを受けた!
モンスターの残りHP:10
----------
勇者の攻撃!
モンスターは10のダメージを受けた!
モンスターの残りHP:0
Test Summary: | Pass  Total
test set      |    3      3
戦闘に勝利した!

これで、安心して置き換えることができる。

function main()
    (略)
    
    @testset begin
        for _ in 1:3
            (略)
            モンスターダメージ = ダメージ計算2(プレイヤー攻撃力, モンスター防御力)
            @test ダメージ計算(プレイヤー攻撃力) == ダメージ計算2(プレイヤー攻撃力, モンスター防御力)
            (略)
        end
    end

    (略)
end

元のダメージ計算関数はもう用がないので、消してしまおう。ついでにテストコードも通らなくなるので消す。

using Test

function ダメージ計算2(攻撃力, 防御力)
    return round(Int, 10 * 攻撃力/防御力)
end


function main()
    モンスターHP = 30
    モンスター防御力 = 10
    プレイヤー攻撃力 = 10

    println("モンスターに遭遇した!")
    println("戦闘開始!")
    
    @testset begin
        for _ in 1:3
            println("----------")
            println("勇者の攻撃!")
            モンスターダメージ = ダメージ計算2(プレイヤー攻撃力, モンスター防御力)
            モンスターHP = モンスターHP - モンスターダメージ
            println("モンスターは" * string(モンスターダメージ) * "のダメージを受けた!")
            println("モンスターの残りHP:" * string(モンスターHP))
        end
    end

    println("戦闘に勝利した!")
end

main()

ダメージ計算2という名前はダサいので、ダメージ計算という名前にしよう。これを行うには、Visual Studio Codeの機能を使うのが良い。関数名にカーソルをあてて、"Rename Symbol"という選択肢を選び、ダメージ計算に変更しよう。これで、ダメージ計算2は、関数名もその呼び出し箇所もあわせてダメージ計算になる。

ついでにusing Test 、@testset beginendを消すと、テストコードを入れる前になって、無事このような形になる。

using Test

function ダメージ計算(攻撃力, 防御力)
    return round(Int, 10 * 攻撃力/防御力)
end

function main()
    モンスターHP = 30
    モンスター防御力 = 10
    プレイヤー攻撃力 = 10

    println("モンスターに遭遇した!")
    println("戦闘開始!")
    
    for _ in 1:3
        println("----------")
        println("勇者の攻撃!")
        モンスターダメージ = ダメージ計算(プレイヤー攻撃力, モンスター防御力)
        モンスターHP = モンスターHP - モンスターダメージ
        println("モンスターは" * string(モンスターダメージ) * "のダメージを受けた!")
        println("モンスターの残りHP:" * string(モンスターHP))
    end

    println("戦闘に勝利した!")
end

main()

まどろっこしく感じられたかもしれない。たしかに、このくらいの規模の変更であれば、テストなしで関数化したり変更をしても、きっと正しく修正できるだろう。

しかし、このような習慣を小さな問題に対しても身につけておくことで、いざ大きな問題に遭遇しても、テストの導入や既存処理の変更に臆せずに取り組むことができるのだ。

また、今回は部分的な変更に対しての前後比較のために自動テストを作成しただけだった。本来は切り出したダメージ計算関数にもテストコードを追加すべきだ。(今日のところはお腹いっぱいだろうから次回に回す)

アプリケーションの規模が大きくなると、ある修正が思わぬ影響を及ぼすことがある。その際に強力な助けとなってくれるのが、こういった永続して残るテストコードなのだが、これは一朝一夕には作ることのできない極めて重要な資産なのだ。

なぜなら、アプリケーションがある程度大きくなってしまうと、そのままの形ではテストコードを作ること自体が難しくなってしまうからだ。そして、テストコードを作ることができる形にするための修正自体がリスクを孕んでしまうという、にっちもさっちもいかない状況になってしまう。ランプの魔人に頼るしかなくなるのだ。

だから、テストコードはアプリケーションとともに少しずつ成長させていくべきだ。コードが小さいうちから、継続的にテストコードを作っていく癖をつけていけば、これはそう難しい話ではない。それができたら、ランプの魔神への3つ目の願いもあなたに決めさせてあげよう。

その1の終わりに

ひとまず切りがいいのでここまでにしよう。次回は、よりゲームらしいアプリケーションにしていきながら、if文、while文、構造体といった概念を学んでいく予定だ。

Juliaの速さの核心に迫る〜インタプリタ、コンパイラ、JITコンパイラ〜

星プログラミング言語Julia。

Rubyの動的さとC言語の速度を両立させた、公認会計士の資格を取得したジャニーズJr.みたいな、そんなのアリかよって感じの言語だ。

Juliaの宣伝文句はすごい。引用してみよう。

僕らが欲しい言語はこんな感じだ。まず、ゆるいライセンスのオープンソースで、Cの速度とRubyの動的さが欲しい。Lispのような真のマクロが使える同図象性のある言語で、Matlabのように分かりやすい数学の記述をしたい。Pythonのように汎用的に使いたいし、Rの統計処理、Perlの文字列処理、Matlab線形代数計算も要る。シェルのように簡単にいくつかのパーツをつなぎ合わせたい。チョー簡単に習えて、超上級ハッカーも満足する言語。インタラクティブに使えて、かつコンパイルできる言語が欲しい。

(そういえば、C言語の実行速度が必要だってのは言ったっけ?)

こんなにもワガママを言った上だけど、Hadoopみたいな大規模分散コンピューティングもやりたい。もちろん、JavaXMLで何キロバイトも常套句を書きたくないし、数千台のマシンに分散した何ギガバイトものログファイルを読んでデバッグするなんて論外だ。幾層にも重なった複雑さを押しつけられるようなことなく、純粋なパワーが欲しい。単純なスカラーのループを書いたら、一台のCPUのレジスターだけをブン回す機械語のコードが生成されて欲しい。A*Bと書くだけで千の計算をそれぞれ千のマシンに分散して実行して、巨大な行列の積をポンと計算してもらいたい。

型だって必要ないなら指定したくない。もしポリモーフィックな関数が必要な時には、ジェネリックプログラミングを使ってアルゴリズムを一度だけ書いて、あとは全ての型に使いたい。引数の型とかから自動的にメソッドを選択してくれる多重ディスパッチがあって、共通の機能がまったく違った型にも提供できるようにして欲しい。これだけのパワーがありながらも、言語としてシンプルでクリーンなものがいい。

これって、多くを望みすぎてるとは思わないよね?

なぜ僕らはJuliaを作ったか

さて、今回はJuliaの速さに注目する。JuliaはC言語並みに速い。なぜそんなに速いのだろうか?

私はぼんやりと、JuliaがJITコンパイルを採用しているからだと思っていた。だって、JITコンパイルというのはJavaC#でお馴染みだ。JavaC#は速い。C言語と同じくらいのオーダーの速度だ。オーダーが同じというのは同じくらいの桁という意味だ。私は言語間の速度の比較で2倍違うとかいう議論はあまり意味がないと思う。*1だから、CもJavaC#もJuliaも同じくらいの速度だとみなす。

一方、PythonRubyは遅い。C言語より1桁とか2桁遅い。これはオーダーが違うので明確に遅い。そしてそれは彼らがインタプリタ方式だからだ。そうだよね?

JuliaはインタプリタではなくJITコンパイルを採用しているから速いのだと、そう思っていた。

しかし、よく考えたら事はそう簡単ではないと思えてきた。PythonRubyの開発者は、散々速度が遅いと文句を言われ続けてきたのだ。私ですらPythonRubyが遅いという話は耳にタコができるほど聞いている。言語設計者はさぞうんざりしていることだろう。JITコンパイルにしてC言語並みに速くなるのであれば、PythonRubyもとうの昔にそうしているのではないか。「うるさい速度マニアどもめ、恐れ入ったか!ガッハッハ!」

しかし、そうはなっていないのだ。

それに、よく考えたらJuliaって動的型付け言語なのだ。Javaや.Netは静的型付け言語だ。静的言語は、いきなり機械語にすることもできるが、あえて中間コードで一旦止めておいて、実行時に機械語にする。これは静的言語のJITコンパイルだ。これはよくわかる。

しかし、動的言語JITコンパイルってなんだろう?考えてみたがよく分からない。それってインタプリタと何が違うの?本当にそれがJuliaが高速な秘訣なんだろうか?

そこで、今日はコンパイラインタプリタあたりの話題を踏まえながら、Juliaの速度の核心に迫る。最初に言っておくが、迫るだけで到達するとは言っていない。個人的にここが重要かなという結論は出すが、それが答えだと言い切る自信は全くない。

だいたい、自分の書いたアプリだって、速度の改善は当てずっぽうでやるな、プロファイリングしろボトルネックを計測しろと口を酸っぱくして言われるのだ。ある言語が別の言語より速い理由など、簡単にわかるはずもない。設計思想も言語仕様もアーキテクチャもユーザーの文化も全然違うのだ。

言い訳も終わったので本題に入ろう。

機械語への翻訳

ソースコードコンパイルすると実行ファイルができる。ソースコードは文字列だ。実行ファイルは機械語だ。ソースコードから何をどうやったら機械語に翻訳できるのだろうか。

ソースコード から機械語へ変換するには、一般的に次のような工程を踏む。

  1. ソースコードに対して、字句解析・構文解析と呼ばれる工程を経て、抽象構文木を生成する。

  2. 抽象構文木から、機械語を生成する。

今回の主題は2の機械語の生成である。まずは抽象構文木について簡単に説明する。その後、機械語の生成の方式について代表的な数パターンを説明する。

抽象構文木

以前の記事でも触れたが、ここでも簡単に触れておく。ちなみに以前の記事の丸コピーである。共通部分としてくくりだした方がいいかと思ったが、面倒なので見送る。こうやってソースコードは汚れていくのだ。

例えば、 "x = 1 + 2" という式を考える。「変数xに1+2の結果を代入する」という意味である。この式はプログラムコードとして与えられる。文字列の"x = 1 + 2"である。このままではただの文字の並び('x', '=', '1', '+', '2')である。ここから何かの意味を抽出したデータ構造に変換する必要がある。意味を抽出して初めて、機械語に翻訳できてコンピュータで実行できるのである。ここで言う、「意味を抽出したデータ構造」が抽象構文木である。

さて、"x = 1 + 2"という文字の並びから、どのような工程を経て、「変数xに1+2の結果を代入する」という意味を持つ抽象構文木に変換するかは重要な問題である。重要な問題ではあるが、私の手には余るので解説しない。興味のある方は、「構文解析」と言う単語で検索されるとよい。人によっては人生が変わるくらい広く深い世界が広がっている。私はその後の人生は保証しない。入門書としては「Go言語でつくるインタプリタ」という本がおすすめである。

www.oreilly.co.jp

ともかく、構文解析という工程を終えると、"x = 1 + 2"という文字の並びから、「変数xに1+2の結果を代入する」という意味の抽象構文木となる。これを図示したのが下記のイメージである。assignというのは、代入を意味するとしておく。この情報があれば、どの順番で何の演算を行えば、所望の結果が得られるのか一目瞭然である。

f:id:muuuminsan:20201003230504p:plain
"x = 1 + 2"に相当する抽象構文木

静的コンパイル方式

いわゆる「コンパイル」と呼ばれる処理だ。ソースコードを書き、コンパイルし、実行ファイルを作成する。例えばC言語がこの方式を採用している。実行時の情報(ソフトウェア起動時の引数や設定データ、画面からの入力情報など)がない状態で、ソースコードのみから機械語への変換を行う。

静的コンパイル方式の重要な特徴の一つに、対象言語が静的型付けであることが挙げられる。なぜなら通常、機械語のレベルでは、同じような演算であっても型によって命令語が異なるからだ。同じ数値であっても、整数値と浮動小数点値では、コンピュータの取り扱いが全く異なる。そのため、同じ加算であっても、整数と浮動小数点小数の加算は全く異なる命令となるのだ。静的に機械語に翻訳できるためには、プログラムを書いた時点で、それぞれの文や式がどのような型を持っているかわかっている必要がある。

動的型付けの言語だと、静的コンパイル方式はやりたくてもできない。

静的型付けの言語は、静的コンパイル方式によって直接機械語に翻訳できるし、後述するインタプリタ方式で間接的に翻訳することもできる。

インタプリタ

上で述べた静的コンパイル方式と対比させるべきは動的コンパイル方式なのだが、先にインタプリタ方式を解説する。

インタプリタ方式に対しては誤解が多いので、ここで一度整理しておく。インタプリタ方式は「ソースコードを逐次機械語へ変換しながら実行すること」を意味すると考えられていることが多いが、これは正確ではない。この説明はどちらかというと動的コンパイルの説明に近い。インタプリタ方式と(静的/動的)コンパイル方式では、機械語を生成する主体が違うのだ。

インタプリタ方式の場合は、処理対象のソースコード (例えばRuby)の処理を模倣する仮想機械を作成する。仮想機械は普通は処理対象のソースコードとは別の言語(例えばC言語)で書かれる。仮想機械が請け負う仕事の範囲は言語によって微妙に異なるが、計算処理そのものを実行するところだけは共通している。それがインタプリタ方式の定義と言ってもいい。

何のことだかよく分からないかもしれないので、簡単な例を出そう。

極小のインタプリタ

今から極めて簡単な仮想の言語とその言語処理系をインタプリタ方式で作る。仮想の言語の前は作者の私の名前をとってmuu言語としよう。インタプリタPythonで作ることにしよう。

muu言語は2つの数の足し算しかできないとても可愛らしい言語だ。"1 + 2"とか"5 + 10"とかいった感じのソースコードとなる。変数は使えない。このソースコード構文解析すると、2つのノードを持つ抽象構文木となる。それぞれのノードには与えられた数字が入っている。

#muu言語の構文解析器
def parse(src):
    left_str, right_str = src.split("+")
    left = int(left_str.strip())
    right = int(right_str.strip())
    return Add(left, right)

#muu言語の抽象構文木
class Add:
    def __init__(self, left, right):
        self.left = left
        self.right = right

muu言語には2つの数の足し算しか存在しないので、抽象構文木として考える必要があるのもAddクラスだけだ。さて、仮想機械はmuu言語に依頼された足し算を実行する必要がある。作成時に抽象構文木を受け取り、evaluateというメソッドで足し算を実行する、そんな仮想機械を考えてみよう。

#muu言語の仮想機械
class MuuMachine:
    def __init__(self, add):
        self.__add = add
    
    def evaluate(self):
        return self.__add.left + self.__add.right

muu言語がインタプリタによってどのように処理されるかのイメージが下記になる。

#muu言語の処理の流れ
src = "5 + 10"  #ソースコード 
a = parse(src) #aにAdd(5, 10)が入る
m = MuuMachine(a) #仮想機械の作成
result = m.evaluate() #仮想機械の実行
print(result) #結果の表示。15となる。

やれ抽象構文木だの仮想機械だの大それた名前はついているが、中身はてんで大したことはない。言語処理系とは何事だと言いたい気持ちはわかる。どうか石を投げないで欲しい。一応最低限のことはやっているのだ。説明させて欲しい。

Addクラスは抽象構文木の役割を果たしている。非常に大きな制約があり、leftとrightの2本しかノードがないし、ノードには数値しか入れられない。しかしまあ、最低限の抽象構文木ではある。

MuuMachineクラスは仮想機械の役割を果たしている。Addクラス以外の抽象構文木が入ってくることに全く対応していないが、何らかの抽象構文木を評価するという意味で、最低限の仮想機械ではある。その証拠に、evaluateするとちゃんと加算の結果を返してくれるのだ。

どうだろうか?だんだん立派な言語処理系という気がしてきただろう。友人のお子さんの小学校の入学祝いに贈ってあげるといい。

さて、この言語処理系が拍子抜けするほど簡単だった理由は、下記の部分にある。

#仮想機械
class MuuMachine:
    ...
    def evaluate(self):
        return self.__add.left + self.__add.right

加算の評価を丸ごとPythonに渡している。「えー、それはずるいよ」と言っているそこの君ね、よく聞きなさい。これがインタプリタの本質なのだ。

確かに、muu言語は何もしていないに等しい。足し算しかできない言語なのに、足し算すらPythonに任せているのだ。とんでもないぐうたら言語だ。みじめで、しみったれた、負け犬言語だ。きっと作者に似たのだろう。足も臭いに違いない。

しかし、待ってほしい。muu言語は今後、飛躍的に発展していくことになる。Addクラスだってノードを任意の数持てるようになるだろうし("1 + 2 + 3 + 4"みたいに書きたいよね?)、ノードに別のAddクラスを入れられるようにだってなるだろう("(1 + 2) + 3 + (4 + (5 + 6))"みたいに書きたいよね?)。何と立派な言語だろうか。その勇姿は大空を駆け巡る美しいペガサスのようだ。

そうしたら、構文解析器は複雑になるだろうし、MuuMachineだってきっと忙しく働くはずだ。ノードを巡回しながら、入っているのが数値なのかAddクラスなのか判断しながら、正しい結果が計算されるように調整しながら加算をしていくことになる。

しかし、最後の最後、数値の加算を行う部分に関しては、やはり基層の言語(この場合はPython)に任せるしかないのだ。それをどうしても自分でやるというのであれば、自前で機械語を生成することになる。それはもはやインタプリタではなく、コンパイラだ。

となると、インタプリタ方式では、実際の計算処理を行う機械語はどこへ行ったのか?基層の言語で書かれた仮想機械のコードが機械語に翻訳されているのだ。

仮に足したい2つの数が5と10だったとしても、pythonで書かれた下記の式は、おそらく実行時に単なる 5 + 10 以上のことを実行している。

self.__add.left + self.__add.right
  • MuuMachineのメンバ変数に__addがあるか?
  • __addはleftとrightというメンバを持っているか?
  • それらがあったとして、leftとrightに入っている値の型は?
    • どちらも整数なら整数同士の加算を行おう。
    • どちらも小数なら小数同士の加算を行おう。
    • 一方が整数で一方が小数なら整数を小数に変換してから小数同士の加算を行う処理を行おう。

Pythonに処理を任せる以上、それは仕方のないことだ。いくら今から計算する足し算は整数同士の足し算で小数を考慮する必要はないと分かっていたとしても、Pythonにそれを伝える手段は存在しない。

このように、インタプリタ方式では、どのような機械語が生成されるかは基層の言語に直接的に依存している。インタプリタの基層の言語としてC言語が採用されていることが多いのは、C言語が指示された以上の余計な機械語を生成しないからだろう。

ここまで見てきたように、インタプリタ方式のメリット・デメリットは明白である。

インタプリタ方式は基層の言語にお任せできる部分が多いため、コンパイル方式より作るのが簡単である。また、基層の言語が動く環境なら動作するのも嬉しい。これがメリットだ。

一方で、速度性能は基層の言語で頭打ちになる。頭打ちになるどころか、だいたいずっと遅くなる。これがデメリットだ。他にもいろいろあるだろうが、代表的なのはこんなところだ。

動的コンパイル方式

動的コンパイル方式とは、実行時に機械語を生成する方式のことだ。JITコンパイルは動的コンパイル方式の一種と言われるようだ。JITコンパイル以外の動的コンパイルについてはよく知らないので、JITコンパイルについて話すことにする。

JITコンパイルとは、Just In Timeコンパイルの略だ。実行時に機械語を生成する。Java.Net Frameworkでもお馴染みの方式だ。

Java.Net Frameworkの言語は、静的型付きの言語である。そのため、静的コンパイルは可能である。しかし、あえて最後までコンパイルを行わずに、静的なコンパイルでは中間言語で書かれた中間コードを生成するにとどめているのだ。

中間言語とは、文字通りソースコード機械語の中間に位置する言語だ。よくILと略される。Intermediate Languageの略だ。ソースコードは人間が読むものだ。機械語はCPUが読むものだ。では中間コードを読むのは誰だろうか?

中間言語を読むのは仮想機械だ。中間言語で書かれた中間コードを読むためのプログラムだ。Javaの場合、JVM(Java Virtual Machine)と略されることが多い。Javaの中間コードはJVMが読む。.Netの中間コードは.Netの仮想機械であるCLR(Common Language Runtime)が読む。仮想機械は中間コードを実際のCPUに対応した機械語に翻訳する。

おや、仮想機械だって?インタプリタにも登場したぞ?そう、JITコンパイル方式は、静的コンパイル方式とインタプリタ方式のいいとこ取りなのだ。もっと言うと、静的コンパイル方式の利点を取り込んだインタプリタ方式がJITコンパイル方式なのだ。

Javaを例に出すのがわかりやすいだろう。Javaの主要な目標の一つに、“Write Once, Run Anywhere”、「一度書けば、どこでも動く」というものがある。一度Javaコードを書くと、WindowsでもMacでもLinuxでも、はたまた電化製品の組み込み環境でも、同じように動いて欲しい。素晴らしい目標だ。

そうなると、静的コンパイル方式では厳しい。ターゲットのCPUが違うとなると、当然命令も違う。存在する全てのCPUで条件分岐して、適切な機械語が実行されるような実行ファイルを作ると言うのも一つのアイデアだが、破綻は目に見えている。

そのようなわけでJavaでは当初インタプリタ方式がとられたようである。各実行環境には、Javaインタプリタをインストールしてもらう。その上で、Javaコードをインタプリタで動かす。Javaインタプリタは、実行環境に合った適切な機械語コンパイルされているため問題なくJavaコードの動きを模倣できる。これで万事解決だ。

ところがこれには実行速度が遅いという問題があったようだ。それでインタプリタではなく、実行時に機械語を生成するJITコンパイル方式に変えたことで、速度の向上に計算したらしい。このおかげで、インタプリタ方式のメリットである「実行環境から独立したポータビリティ」と、静的コンパイル方式の「実行速度」を手に入れることができたのだ。

なお、Microsoft.Net Frameworkも同様に中間言語を使った仮想機械方式だが、こちらはJavaとは目的が異なる。Javaは複数のCPUをターゲットにしていたが、.NetはWindowsのみをターゲットにしていた。*2

.Net Frameworkがサポートしていたのは、複数の言語だ。C#VB.net、F#、J#などなど。これらの.Netファミリー言語は、同じ中間コードに翻訳される。別の言語で書かれたメソッドもdll経由で簡単に呼び出せるように設計されている。.Net Frameworkの巨大なライブラリが簡単に再利用可能なのだ。

他に、JITコンパイルの別の利点として、機械語の生成を実行時まで遅らせることで、まさに実行時の環境に最適な機械語を生成するということが可能になる。CPUの特性に応じて機械語を変えることができるし、CPUの使用率やメモリの空き状況に応じて、生成する機械語を変えるということも可能である。もちろん、実行時の処理のため機械語の生成にあまり時間をかけるわけにはいかないので、制約は大きいが、原理的にはそのようなメリットもある。

このように、JITコンパイルには様々なメリットがある。JuliaはLLVMというツールを使って、この方式を実現している。

LLVMというのは、Juliaとは独立したツールだ。LLVMLLVM用の中間コードを受け取り、機械語を出力する。Juliaの評価器はプログラムの実行時に、Juliaのソースコード からLLVMへの入力となる中間コードを生成している。LLVMは中間コードを受け取り即座に機械語を出力する。その機械語は即座に実行される。これがJuliaのJITコンパイルだ。

Juliaはなぜ速いのか

そろそろ基礎知識も出揃ってきたので、Juliaがなぜ速いのかについて本腰を入れて考えよう。とはいえ、どこから考えればいいかよく分からない。

過去にJuliaを遅くする試みがなされていれば、何が原因で速くなっているかの切り分けができるのだが、生憎のところ速い言語を遅くする試みは人気がない。

そこで、Julia以外の言語の試行錯誤の事例を手掛かりにしよう。遅い言語を速くしようという試みはたくさんあるからだ。

今から、速い言語の代表としてJavaを考える。遅い言語の代表としてはRubyを考える。

さて、JavaRubyとJuliaの比較をしてみよう。

Java Ruby Julia
静的型付け 動的型付け 動的型付け
JITコンパイル方式 インタプリタ方式 JITコンパイル方式
速い 遅い 速い

これを見たら、静的型付けか動的型付けは速度に依存せず、JITコンパイル方式なのかインタプリタ方式なのかが重要なように見える。本当だろうか?

Javaの事例

先ほど、Javaは当初インタプリタ方式だったが、速度の問題でJITコンパイル方式に変えて速くなったという話をした。ここを少し深掘りしよう。

一口にインタプリタ方式と言っても、いろいろな方式がある。最も素朴な方式は、抽象構文木を直接評価する方式のインタプリタだった。これは実装が非常にシンプルなのだが、あまり効率的ではないという問題がある。抽象構文木を巡回しながら値を評価するので、時間がかかるのだ。抽象構文木を巡回するということのイメージはわくだろうか?

足し算の抽象構文木を考えよう。muu言語が少し成長して、入れ子になった足し算をサポートしたとしよう。(1 + (2 + 3)) + ((4 + (5 + 6)) + 7) みたいな形だ。この時、抽象構文木は次のようになる。

f:id:muuuminsan:20201105224008p:plain
入れ子になった足し算の抽象構文木

これは一例だが、このような足し算構文木の計算を実行するためには、どのようにすればいいだろうか?

少し用語のを導入する。丸の部分をノードという。てっぺんのノードをルートノードといい、枝分かれした分岐の先端のノードをリーフノードという。

まず、ルートノードには、「+」という記号があり、これは足し算の構文木だということを示している。次に子ノードを見てみる。このとき、両方の子ノードに数値が入っていればそれでOKだ。足してあげればそれでおしまいだ。muu言語がこれだ。

しかし、子ノードを見ると、またしても「+」記号があるとする。そうすると、これは子ノードが抽象構文木だということなので、子ノードに潜る必要がある。そこからさらに子のノードを見ると、数値だったり「+」記号だったりする。「+」記号であれば、それは抽象構文木ということで、さらに潜る必要がある。延々と潜って行って、両方のノードが数値になる先端(リーフノード)まで行き当たると、ようやく足し算が実行できる。足し算の結果を手土産に上のノードに戻る。そうすると、そのノードが計算できるかもしれない。計算できればさらに上に戻る。しかし、もう一方のノードが抽象構文木であれば計算できないので、今度はそちらを下に辿っていく。先端に辿り着いたら計算し上に戻っていく。

こうして、計算できるまで下のノードに下りていき、下のノードの計算ができればその計算結果を引っ下げて上のノードに戻るということを、繰り返しやっていき、ルートノードの計算が終わると、最終的には元々やりたかった足し算が完了する。

このように、実行時にいろいろな判断をしながら抽象構文木を行ったり来たりして計算するのはいかにも効率が悪そうということがわかるだろう。

これを改善した方式として、抽象構文木からバイトコードと呼ばれる中間コードを生成して、インタプリタバイトコードを解釈するという方式である。バイトコードというのは抽象構文木とは違い、一直線に並んだ命令と数値の列である。命令と数値が適切な順番で並べられているため、端から順に何も考えずに命令を実行すれば所望の結果が得られるというものになっている。こうしておくと、命令の実行時に条件判断が入らないし、行ったり来たりもないため、高速なのだ。

私は最初、インタプリタ方式のJavaが遅かったと聞いて、ソースコードから逐次変換していたために遅かったのだと勘違いしていた。ソースコードからインタプリタ渡すまでの、字句解析、構文解析、抽象構文木の作成あたりに時間がかかったと思っていたのだ。しかし、この特徴は動的コンパイルの特徴であり、静的コンパイル可能なJavaでその必要はない。

実際にはインタプリタ方式のJavaは、そのあたりの処理は全て行い、なおかつバイトコード形式の中間コードを事前に生成するところまで行っていたようである。それを実行環境で解釈するという方式がとられていたようなのだ。インタプリタ方式では考えられる最高の方式だ。それでも遅かったようなのだ。

そして、最後の最後、中間コードを仮想機械が実行するのではなく、仮想機械が機械語を生成・実行する。これが必要だったようなのだ。

おそらくJITコンパイル方式に変更するという決断を下す前に、バイトコード設計やインタプリタの最適化は相当入念に行われたことだろう。それでも越えられない壁がそこにはあったようなのだ。

なるほど、インタプリタ方式とJITコンパイル方式には大きな差があるようである。しかし、それだけなのだろうか?

Rubyの事例

最初の方で話したが、インタプリタ方式からJITコンパイル方式に変えるだけで高速化するのであれば、Rubyだってとっくにそうしているだろう。動的言語だからJITコンパイルできないのだろうか?いや、そんなことはない。動的言語はコーディング時に型を指定する必要はないが、実行時にはきちんと型を持つのだ。値そのものが型を持つからだ。なので、実行時に機械語に変換できない道理はないのだ。

ではなぜ、RubyJITコンパイルを行わないのか?

実はRubyJITコンパイルを行っているのだ。Ruby2.6でMJITという仕組みが導入された。これは面白い仕組みで、中間コードとしてC言語を使う。Javaや.Netの中間コードはかなり機械語チックだが、MJITはC言語なのだ。Rubyバイトコードを解釈するRubyインタプリタは、何度も実行される処理に対してはバイトコードと同じ動作をするC言語ソースコード を吐き出し、それを機械語にする。公式によるとRuby2.5と比べて1.7倍の高速化が実現されたそうだ。素晴らしい。

www.ruby-lang.org

しかし、C言語Javaのレベルには程遠いのは確かだ。こうなってくると、JITコンパイル「だから」高速だというのは怪しくなってくる。JITコンパイルは高速化の大きな要因の一つだが、それだけではなさそうなのだ。

ここでMJITが何をしているかを紹介している面白い記事があるので紹介しよう。

techracho.bpsinc.jp

原文はこちらだそうである。

簡単に抜粋すると、筆者は

a = a * 16807 % 2147483647

というコードを含むrubyプログラムを書き、MJITがどのようなC言語を生成するかを観察されたようである。

最初の乗算で行われるすべての内容を以下のセクションに示します。

ローカル変数aを取得する(スタックにpushされている)

被乗数16807をスタックにpushする(object_id 0x834fで表されている)

スタック上の2つの値を引数としてvm_opt_multを呼び出す

上記のような処理を実行するC言語コードが出力されるようである。気になるのは、乗算を実行してくれるvm_opt_multというものが何者かということだ。

ruby 2.6.3のソースコードからvm_opt_multを抜粋すると次のような処理になっている。じっくり読む必要はないが、if文の条件式に注目してほしい。FIXNUMだとか、FLONUMだとか書いている。これは変数の型を判定しているのだ。乗算の引数の型を判定し、処理を振り分けるようになっている。

static VALUE
vm_opt_mult(VALUE recv, VALUE obj)
{
    if (FIXNUM_2_P(recv, obj) &&
    BASIC_OP_UNREDEFINED_P(BOP_MULT, INTEGER_REDEFINED_OP_FLAG)) {
    return rb_fix_mul_fix(recv, obj);
    }
    else if (FLONUM_2_P(recv, obj) &&
         BASIC_OP_UNREDEFINED_P(BOP_MULT, FLOAT_REDEFINED_OP_FLAG)) {
    return DBL2NUM(RFLOAT_VALUE(recv) * RFLOAT_VALUE(obj));
    }
    else if (SPECIAL_CONST_P(recv) || SPECIAL_CONST_P(obj)) {
    return Qundef;
    }
    else if (RBASIC_CLASS(recv) == rb_cFloat &&
         RBASIC_CLASS(obj)  == rb_cFloat &&
         BASIC_OP_UNREDEFINED_P(BOP_MULT, FLOAT_REDEFINED_OP_FLAG)) {
    return DBL2NUM(RFLOAT_VALUE(recv) * RFLOAT_VALUE(obj));
    }
    else {
    return Qundef;
    }
}

ここがあまりRubyのパフォーマンスが向上しない部分なのではないかと思う。機械語にしているとは言え、この関数が生成する命令文は単なる乗算の命令文よりもはるかに長大なものになるだろう。

型による実行時の条件分岐が足を引っ張っているのではないか、ということが、Rubyの事例からの推測だ。動的言語であるという点も、無視できないポイントのようだ。

Pythonの事例

Rubyでの推測を補強するべく、Pythonの事例を引用しよう。

PythonにもJITコンパイルを行うという試みがある。本家の処理系であるCPython以外に、PyPyという処理系やnumbaというライブラリがあり、これはJITコンパイルを行うのだ。これは本家のCPythonよりも圧倒的に速いという。数十倍速いこともあるらしい。これは文句なしに速いと言っていいだろう。

PyPyは、Pythonの機能制限版であるRPythonというものを対象にした処理系だ。RPythonの特徴は、型推論が可能な機能に絞っているということだ。型推論・・・Juliaでも聞いたことあるだろう。プログラマが明示的に型を指定しなくても、実行時に与えられた値が持つ型から、論理的に各パーツの型を特定していく機能のことである。推論であって推測ではないので、厳密に定まる。Pythonの一部機能がこの型推論と相性が悪いらしいので、そのあたりの機能を取っ払ったのがRPythonで、RPythonの処理系の1つが型推論を行うPyPyであるという。

numbaも同様に型推論を行う。PyPyと違い別の処理形ではなく、本家のPythonで利用できるライブラリだ。関数に@jitというデコレータをつけると型推論してJITコンパイルをしてくれるという優れものだ。まあ、いろいろ注意点もあるようだが、あまり深掘りはしない。numbaの適用範囲もまた、PyPyと同じくPythonの一部機能に限られている。

さらにはCythonというものもある。Cythonというのは型宣言できるPythonとでも言うべき言語で、基本的にはPythonと同様に型宣言せずにコーディングできるのだが、高速化したいところに関しては型を宣言することができると言うものだ。Cythonの処理系はPythonで書かれているらしいので、Pythonにできることは何でもできる(はずだ)。Cythonも高速化に型を利用している。

そう、型なのだ。型を演算時に判定する必要があるかどうかが、実行速度に大きく影響しているようなのだ。型宣言や型推論など、型に関する支援があるかどうかが分かれ目なのだ。

上の方に書いた表を見直す必要がある。速い言語になるには、型支援とJITコンパイルが必要なようだ。

Java(旧) Java Ruby(2.6) Julia
静的型付け 静的型付け 動的型付け 動的型付け
型支援あり 型支援あり 型支援なし 型支援あり
インタプリタ方式 JITコンパイル方式 JITコンパイル方式 JITコンパイル方式
遅い 速い 遅い 速い

Juliaの型推論と最適化

Juliaの型推論と最適化について見てみよう。Juliaの非常に優れた特徴の一つに、低レイヤのコードを容易に確認できるというものがある。Juliaコードが、どのような過程で機械語に変換されていったかわかる機能が、標準でついているのだ。使うのは次の4つ、@code_lowered、@code_typed、@code_llvm、@code_nativeだ。後のほうに行くほど機械語に近付いていく。

試しに、次のような極めて簡単な関数doubleがどのような機械語に変換されるか見てみよう。

function double(x)
    return 2 * x
end

ここからのサンプルを実行する際、Replならそのまま打ち込めば良いが、スクリプトファイルに記述して実行するときには、

using InteractiveUtils

を忘れるとエラーになるので注意してほしい。

@code_lowered

@code_loweredは、コードを「低レベル」な呼び出しに変換してくれる。低レベルというのは機械語に近いという意味だ。

ソースコード上でdouble(3)書いたものが、@code_loweredでどのように変換されるか見てみよう。

@code_lowered double(3)
CodeInfo(
1 ─ %1 = 2 * x
└──      return %1
)

2とxをかけた結果を%1という入れ物に入れて、%1をreturnする、と読める。これはまあそんなものかな、くらいのものだろう。ひとまず次に移ろう。

@code_typed

@code_typedから、俄然面白くなる。この時点でJuliaは型を推論するのだ。

@code_typed double(3)
CodeInfo(
1 ─ %1 = Base.mul_int(2, x)::Int64
└──      return %1
) => Int64

先ほどとの違いは、mul_intという呼び出しが現れていることだろう。::Int64と書いているので、これが整数の掛け算だということがわかる。

一方、引数に小数である3.0を渡すとどうなるか?

まず、@code_loweredは何も変わらない。

@code_lowered double(3)
CodeInfo(
1 ─ %1 = 2 * x
└──      return %1
)

しかし、@code_typedの結果は変わる。

@code_typed double(3.0)
CodeInfo(
1 ─ %1 = Base.sitofp(Float64, 2)::Float64
│   %2 = Base.mul_float(%1, x)::Float64
└──      return %2
) => Float64

sitofpという処理で、2をFloat64に変換して、その後、小数の掛け算を行う処理になっている。このように、同じ関数呼び出しであっても、引数に入ってきた値の型に応じて、@code_typedの時点で、どの型の命令を呼び出せば良いか判明しているのだ。

@code_llvm

@code_llvmは、LLVMコンパイラへの入力である、LLVM中間コードを表示する。

@code_llvm double(3)

結果は次のようになる。デバッグ情報は消している。この規模だと逆に邪魔だからだ。

define i64 @julia_double_802(i64) {
top:
   %1 = shl i64 %0, 1
  ret i64 %1
}

かなり機械語のようになってきた。ここで注目すべきは、

   %1 = shl i64 %0, 1

だ。%0は引数で、この場合は3が来ることになる。shlとは何かというと、シフト演算だ。シフト演算とは、メモリ上の数値を1ビット右や左にずらすことだ。なぜこんな演算が登場したか。

これは、整数の掛け算/割り算を高速に行う手法なのだ。左に1ビットシフトすると2倍の数値になる。右に2ビットシフトすると、2で割った数値になる。ビット演算は掛け算よりも高速なので、与えられている数値が整数で、かつ、掛ける数か割る数が2の倍数のときには高速化に有効な手法なのだ。こんな芸当は型が整数であると分かっていなければ絶対にできない。

次に引数が小数のケースを見てみよう。

@code_llvm double(3.0)

結果は次のようになる。

define double @julia_double_809(double) {
top:
   %1 = fmul double %0, 2.000000e+00
  ret double %1
}

今度はfmulという命令になった。小数の掛け算の命令だ。fmul直後のdoubleは関数名のdoubleではなく型名のdoubleだ。%0は同じく引数、掛ける数は2を小数に変換した数だ。普通は整数の掛け算だと、これに似た mul という命令になるが、先ほどは一方の数が2だったので、気を利かせてさらに高速な shl にしてくれたわけだ。

@code_native

@code_nativeはLLVMが生成する機械語を表示してくれる。これに関しては雰囲気だけ見ておこう。JuliaというよりはLLVMの領域の話だからだ。

@code_native double(3)
.section        __TEXT,__text,regular,pure_instructions
leaq    (%rdi,%rdi), %rax
retq
nopw    %cs:(%rax,%rax)
nop

leaqというのは、メモリ操作のためのアドレス演算命令で、普通は計算には使われないが、うまく利用することである種の算術演算が高速化されるというものだ。わかりやすそうな日本語の文献として下記のサイトを見つけたので興味があれば参照してほしい。ちなみに私は理解していない。

性能とプロファイル – OCaml

(訳注: ここは一見わかりやすい add 命令(普通の整数演算)でなく lea 命令(メモリ操作のためのアドレス計算を転用して演算する) 使っていることを指している。が、 もっと古い CISC マシンである メインフレームでも lea 相当の命令(LA)で 加算をするのは普通なので、 x86 設計チームもこういう使いかたは知っているだろう。 実際に add も lea も Pentium 以降は1サイクルで動作する最速動作の命令であり、 一命令で加算と定数減算をやっている lea 命令を使わない場合、 add した後に別途1を引かないといけない (sub命令も必要)分遅くなるのだ)

面白いのは、Juliaは型推論の末にビットシフトをしろとLLVM中間コードで命令していたが、LLVMがさらに気を利かせて、こっちの方が速いぜと別の命令を下していることである。それぞれの層で最適化を行っているのだ。

@code_native double(3.0)
.section        __TEXT,__text,regular,pure_instructions
vaddsd  %xmm0, %xmm0, %xmm0
retq
nopw    %cs:(%rax,%rax)
nop

vaddsdというのは小数の足し算の命令だ。自分同士の足し算を行って、自分自身に代入している。自分自身との足し算ということはジャスト2倍ということだ。Juliaが生成したLLVM中間コードでは、整数の2倍ではなくて小数の2.0倍だったが、LLVMの内部では、再度整数の2とみなして掛け算より足し算したほうが速いと判定されたらしい。

このように、型の情報をフル活用して非常に高速な機械語が生成されたことがわかる。さらにLLVMも気を利かせて色々な最適化を施してくれている。

Juliaは引数からどこまでの情報を判定しているか?

Juliaは引数の型の情報を使って型推論していることはわかった。では、Juliaは引数の情報をどこまで使って最適化するのだろうか?

次の例を見てみよう。先ほどのdoubleと違い、引数を2つとる。

function multiply(x, y)
    return x * y
end

次のように引数を与えると、doubleとmultiplyは同じ計算をする。

double(3) #6
multiply(2, 3) #6

@code_typedの結果は次のようなもので、大した違いはない。

#@code_typed double(3)
CodeInfo(
1 ─ %1 = Base.mul_int(2, x)::Int64
└──      return %1
) => Int64

#@code_typed multiply(2, 3)
CodeInfo(
1 ─ %1 = Base.mul_int(x, y)::Int64
└──      return %1
) => Int64

ただ、@code_llvmを見ると、差が出ていることがわかる。doubleでは高速なshl命令が使われているが、multiplyでは通常の掛け算の mul命令だ。

;@code_llvm double(3)
define i64 @julia_double_817(i64) {
top:
   %1 = shl i64 %0, 1
  ret i64 %1
}

;@code_llvm multiply(2, 3)
define i64 @julia_multiply_818(i64, i64) {
top:
   %2 = mul i64 %1, %0
  ret i64 %2
}

いずれのケースでも2 * 3を計算するが、2という情報を活かしたのは、前者のみだったことがわかる。このことは、Juliaは引数の値の型は考慮するが、引数の値まで考慮した最適化はしないということを示している。一方、コード中の即値としてして書かれた値は最適化に活用するようである。

しかし、私はここは声を大にして言いたいが、だからと言って関数に定数を埋め込みまくれと言いたいわけでは決してない。もっと言うと、常に機械語を意識してプログラムを書けと言いたいわけでもない。

関数で何を引数として受け取り、何をハードコーディングするかは、速度を基準として決めるべきではない。まずはこう言った速度のあれこれは忘れよう。関数として最も自然なインターフェースが何かを考えよう。使いやすくて読みやすい関数を作ろう。独立性が高くて柔軟な関数を作ろう。その上でプログラムを動かし、遅いようであればボトルネックを特定しよう。

Juliaには非常に便利な@timeマクロや@profileマクロがある。これはもう信じがたいくらいに手軽で便利だ。これを使ってボトルネックを特定したら、初めて低レベルのコードを見てみよう。順序を間違えてはならない。

とはいえ、何かの役に立つかもしれないから知っておいても損はないだろう。

Juliaのその他の最適化

Juliaの賢い最適化をもう少し鑑賞してみよう。

最適化手法の代表的なものの一つに、機械語にするよりも前の段階で、できる部分の計算はやってしまおうと言うものがある。たとえば、コード中に 2 * 3 * 4 という掛け算があるとき、CPUで物理的に乗算命令を実行するのではなく、 2 * 3 * 4 = 24 だと論理的に計算してしまったほうが速いと言うテクニックのことである。これを定数の畳み込みという。

次のようなコードを考えよう。squareは引数を2乗する関数、sum_squaredは2乗した結果を100回ループして合計する関数である。

function square(x)
    return x * x
end

function sum_squared(n)
    s = 0
    for i = 1:100
        s += square(n)
    end
    return s
end

これに対して、sum_squared(5)と呼び出すと、5 * 5 = 25を100回足し合わせるので2500になる。このsum_squared(5)の低レベルコードを見てみよう。

@code_lowered と @code_typed はそこまで変わったところはない。ループは条件分岐とgotoに置き換えられているが、100回ループしたら処理を終わるのだな、というあたりは変わっていない。

#@code_lowered sum_squared(5)
CodeInfo(
1 ─       s = 0
│   %2  = 1:100@_4 = Base.iterate(%2)
│   %4  = @_4 === nothing
│   %5  = Base.not_int(%4)
└──       goto #4 if not %5
2 ┄ %7  = @_4
│         i = Core.getfield(%7, 1)
│   %9  = Core.getfield(%7, 2)
│   %10 = s
│   %11 = Main.square(n)
│         s = %10 + %11@_4 = Base.iterate(%2, %9)
│   %14 = @_4 === nothing
│   %15 = Base.not_int(%14)
└──       goto #4 if not %15
3 ─       goto #2
4return s
)

#@code_typed sum_squared(5)
CodeInfo(
1 ─       goto #7 if not true
2 ┄ %2  = φ (#1 => 0, #6 => %5)::Int64
│   %3  = φ (#1 => 1, #6 => %11)::Int64
│   %4  = Base.mul_int(n, n)::Int64
│   %5  = Base.add_int(%2, %4)::Int64
│   %6  = (%3 === 100)::Bool
└──       goto #4 if not %6
3 ─       goto #5
4 ─ %9  = Base.add_int(%3, 1)::Int64
└──       goto #5
5 ┄ %11 = φ (#4 => %9)::Int64
│   %12 = φ (#3 => true, #4 => false)::Bool
│   %13 = Base.not_int(%12)::Bool
└──       goto #7 if not %13
6 ─       goto #2
7 ┄ %16 = φ (#5 => %5, #1 => 0)::Int64
└──       return %16
) => Int64

ところが、@code_llvmは面白い。ループが消え失せて、%0の引数を2乗した次は、それに100を掛けているだけだ。100回足し算するなら、100を掛けたらいいんでしょうというクレバーな判断だ。

;@code_llvm sum_squared(5)
define i64 @julia_sum_squared_815(i64) {
top:
    %1 = mul i64 %0, %0
  %2 = mul i64 %1, 100
  ret i64 %2
}

次に、sum_squaredに少し手を加えて、square(n)の部分を square(i * n)としてみよう。適当に変えてみただけで、何か深い意味のある計算というわけではない。

function sum_squared(n)
    s = 0
    for i = 1:100
        s += square(i * n)
    end
    return s
end

@code_loweredと@code_typedはあまり変わらないので割愛する。@code_llvmは次のようになる。

;@code_llvm sum_squared(5)
define i64 @julia_sum_squared_815(i64) {
top:
  %1 = mul i64 %0, %0
  %2 = mul i64 %1, 338350
  ret i64 %2
}

338350というのは、1から100までの数字を2乗して足し上げた数である。square(i * n) = square(i) * square(n) で、後者square(n)はiのループ内で定数なので外に出されて、iのループを計算した結果338350と掛け算されたのだ。

さらに、square(i * n)の部分をsquare(i + n)に変えてみよう。これは先ほどみたいに簡単にはくくり出せないはずだ。

function sum_squared(n)
    s = 0
    for i = 1:100
        s += square(i + n)
    end
    return s
end

@code_llvmを見ると、次のようになっている。

;@code_llvm sum_squared(5)
define i64 @julia_sum_squared_815(i64) {
top:
  %1 = add i64 %0, 1
  %2 = mul i64 %1, %1
  %3 = mul i64 %2, 100
  %4 = mul i64 %0, 9900
  %5 = add i64 %3, %4
  %6 = add i64 %5, 338250
  ret i64 %6
}

(i + n) * (i + n) = i*i + 2*i*n + n*n を i を1から100にわたって足し上げると、上記コードの定数の部分が出てくるのだろう。定数の畳み込みにより、ループがただの四則演算に変わってしまった。型推論とは関係ないが、Juliaが強力な最適化機構を持っていることがわかる。

型推論できないとき

型推論ができないときの動きを見てみよう。Juliaには、const指定されていないグローバル変数については型推論ができないという仕様がある。ということは、const指定されていないグローバル変数にアクセスする関数を作ってみれば、型推論できない時にどのような低レベルコードを生成するかわかるだろう。

次のような関数を考える。

a = 0

function assign_a(x)
    global a = x
end

この上なく簡単だ。assign_a(1)はどのような低レベルコードを出すだろうか?

@code_typedはおとなしい。::Anyは型推論が効いていないことを示している。

#@code_typed assign_a(1)
CodeInfo(
1 ─     (Main.a = x)::Any
└──     return x
) => Int64

そしてこれが@code_llvmだッ!!!

;@code_llvm assign_a(1)
define i64 @julia_assign_a_763(i64) {
top:
  %gcframe = alloca %jl_value_t*, i32 3, align 16
  %1 = bitcast %jl_value_t** %gcframe to i8*
  call void @llvm.memset.p0i8.i32(i8* align 16 %1, i8 0, i32 24, i1 false)
  %2 = call %jl_value_t*** inttoptr (i64 4553897056 to %jl_value_t*** ()*)() #4
  %3 = getelementptr %jl_value_t*, %jl_value_t** %gcframe, i32 0
  %4 = bitcast %jl_value_t** %3 to i64*
  store i64 4, i64* %4
  %5 = getelementptr %jl_value_t**, %jl_value_t*** %2, i32 0
  %6 = load %jl_value_t**, %jl_value_t*** %5
  %7 = getelementptr %jl_value_t*, %jl_value_t** %gcframe, i32 1
  %8 = bitcast %jl_value_t** %7 to %jl_value_t***
  store %jl_value_t** %6, %jl_value_t*** %8
  %9 = bitcast %jl_value_t*** %5 to %jl_value_t***
  store %jl_value_t** %gcframe, %jl_value_t*** %9
  %10 = call %jl_value_t* @jl_box_int64(i64 signext %0)
  %11 = getelementptr %jl_value_t*, %jl_value_t** %gcframe, i32 2
  store %jl_value_t* %10, %jl_value_t** %11
  call void @jl_checked_assignment(%jl_value_t* nonnull inttoptr (i64 4654483200 to %jl_value_t*), %jl_value_t* %10)
  %12 = getelementptr %jl_value_t*, %jl_value_t** %gcframe, i32 1
  %13 = load %jl_value_t*, %jl_value_t** %12
  %14 = getelementptr %jl_value_t**, %jl_value_t*** %2, i32 0
  %15 = bitcast %jl_value_t*** %14 to %jl_value_t**
  store %jl_value_t* %13, %jl_value_t** %15
  ret i64 %0
}

うぎゃぎゃぎゃー!と叫びたくなるようなコードだ。先ほどまでの美しいLLVM中間コードを返しておくれ。

機械語の例は出さないが、雰囲気は似たようなものだ。この例を見ることで、型推論が高速なコードにいかに寄与しているか想像できると思う。

おわりに

長々と書いてきたが、ようやく終わりである。おさらいしよう。

Juliaの速さを支えているのは、型推論JITコンパイルだ。おそらくどちらが欠けてもいけないのだが、より言語仕様に深く影響するのは型推論だ。

PythonRuby型推論と相性の悪い機能があるため、今一つ速度が出せていない。それはPythonにおいて、言語機能を制限すれば型推論と高速化に成功しているという事例からもうかがえる。Juliaは言語設計の段階から型推論と相性の悪い機能を注意深く排除してきたのだろう。

では、PythonRubyにあって、Juliaには存在しない機能はあるのだろうか?PythonとRPythonの機能の差分に答えはあるはずだ。それが型推論を実現するために捨て去られた機能だからだ。それが何なのか、私にはよくわかっていない。私はPythonの高度な機能についてはよくわからないのだ。

しかし、Juliaには極めて強力なマクロ機能がある。私はなんとなく、そういった機能もマクロが解決してくれるのではないかとぼんやり思っている。思っているというよりは願っていると言ったほうが正確だ。どうなんだろう?誰か知っていたら教えて欲しい。

*1:もちろん、個々のアプリとしては2倍差が非常に重要になることはありうる。

*2:最近はマルチプラットフォーム対応にも力を入れている

Lispはなぜ神の言語と呼ばれるのか

古から存在するプログラミング言語Lisp。その通り名の一つに「神の言語」というものがある。

なんという甘美な響きだろうか。この名前だけで男子中学生は全員習得することを決心するだろう。そのくらいインパクトのある名前である。文部科学省はプログラミング教育を推進するにあたって公式の採用を真剣に検討されると良い。

さて、「神の言語」の勉強を始めてみて驚くだろう。どのあたりが神なのかよくわからないのだ。たしかに丸括弧は多い。多いが、それと神との関連性はよくわからない。

何か土着の信仰の話なのだろうか?そう戸惑うのも無理はない。

私自身、前からこのフレーズは聞いたことがあったが、あまり理解していなかった。そこで今回改めて調べてみたので共有しようと思う。

「神の言語」の由来

そもそも、Lispは神の言語だと誰が言い出したのか?どうやら海外でそのような歌が作られたというのが始まりのようである。Bob Kanefskyという方が"Eternal Flame"というタイトルで、2000年にリリースされたそうである。いきなり濃い話である。Pythonの歌とか、C++の歌とか聞いたことがあるだろうか?*1

あまりに狂信的なエピソードなので、やっぱり土着の信仰なのかもしれないと少し心配になってくるが、まずは中身を見てみるとしよう。

日本語訳を掲載されているページがあったので紹介する。

www.geekpage.jp

C、C++PerlFortranJavaCOBOL、APLが引き合いに出され、神はこれらの言語を全て知っているが、その上でLispを選んだ、Lispで世界の全てを作った。なぜなら神には締め切りがあって6日間で世界を作らなければならなかったから、というのが要旨だ。

6日間で世界を作るというのが難しい仕事であるということは想像に難くない。私なら少なく見積もっても2ヶ月はかかるだろう。そのような難しい仕事を実現できるのは、神の力を持ってしても道具を選ぶ必要があり、その道具がLispであるということだ。

歌詞の中ではそれ以上の理由は述べられていないので、別の文献に当たる必要がある。

Lispはなぜ難しい仕事に向いているのか

Lispがなぜ難しい仕事に向いているのか、ということについては、下記に紹介されているページで明快に記述されている。Gaucheの作者である川合史朗氏の名文である。GaucheというのはSchemeの処理系で、SchemeというのはLisp族の主要な一派である。私はこのような素晴らしい文章を読むと定期的に読み返してしまう。

practical-scheme.net

practical-scheme.net

ちゃんと読んだだろうか?多分、ちゃんと読んだ人は面白くて別の記事も読み始めたことだろうし、きっとそちらの方が有意義だろう。なので、私の記事に戻ってくるのはそのうちでいいのだが、時間が空いた時に私の話も聞いて欲しい。

さて、この世の中でプログラムで解決する課題は大きく二つに分かれる。解くべき問題が明確になっていて、あとはコンピュータにプログラムを書くだけ(それだってとても難しい)という問題と、解くべき問題すら明確になっておらず、プログラムを作りながら何を解けばいいかを理解していくような問題である。

当然、前者の問題が世の中の圧倒的多数を占めていて、後者のような難しい問題はちょびっとである。*2

後者のような問題の代表例として、人工知能の開発のような問題が考えられる。最近では人工知能というのはパターンマッチであるということで相場が決まった感があるが、昔はもっといろいろ試行錯誤されていたらしい。当時、人工知能の研究者の間でメインで使われていたのがLispだったので、Lispとは人工知能向けの言語だという言説すらある。

後者のような問題領域でLispがなぜ有効なのか、ということを川合史朗氏が述べられているのが先ほどのページである。

もちろん世界を作るというのも後者に属する問題なので、神もLispを使っているのだ。これがLispが神の言語と呼ばれている理由である。

おまけ

どうせなのでもう少し語らせてもらいたい。

Lispがいかに素晴らしいかの話をする際に、後者のような領域ですごく役に立つという話はあるのだが、前者のような領域ではLispはどうなのだろうか、という観点ではあまり語られていない気がするのである。結局前者のような「普通の」問題を解くことのほうが多いのだ。そんなときはどうすべきなのだろうか?

まず個人の趣味でコードを書く場合。これはもう答えは決まっている。好きな言語を使うべきだ。ただ、迷ったならLispを試して欲しい。括弧に目が慣れたらわかるが、Lispは美しい。Lispでのプログラミングはとても快適だ。美しい再帰関数を書けたときなど最高にスカッとする。それになぜかわからないが、私はしばらくLispを書いていないと、ある日急にLispのコードを書き殴りたくなるのだ。

一方、製品レベルのコードを書く場合、Common Lispにしろ、Schemeにしろ、ネックになるのがライブラリである。いや、私はライブラリの選択に困るほど本格的にLispを使いこなしてはいないのでよくわからないのだが、聞くところによるとあまり充実していないのだ。

こういったところを気にされるのであれば、Clojureという言語の存在を知っておくといい。この言語はJVM(Java Virtual Machine)上で動くLisp族の新星で、Javaのライブラリを呼び出せるのでとても安心感があるらしい。Mebabaseという便利でかっこいいデータ可視化ツールがあるのだが、このツールがClojureで書かれていると知って私はすっかり嬉しくなってしまった。しかもPythonからわざわざ乗り換えたらしいのだ。Clojureがとても実用的な言語であることの証明だ。

私はLisp初心者がいきなりClojureというのは学ぶ内容が多すぎる気がするので、まずはSchemeCommon Lispをやって、その後にClojureをしたほうがいいと思う。

何にせよ、Lispは「普通の」プログラミングでも役に立つぞと、声を大にして言いたいのだ。

*1:この手の話で私が他に知っている唯一の事例はシュレディンガー音頭だ。

*2:いや実際には後者のような問題はたくさんあるが、時間とお金の投入が許されるのは主に前者だというだけかもしれない。

非オブジェクト指向言語Juliaで書くオブジェクト指向

数値計算業界に突如彗星の如く現れたJulia。

Rubyの動的さとC言語の速度を両立させた、東大理三卒の弁護士みたいな、そんなのアリかよって感じの言語なのだけれど、この言語、オブジェクト指向ではないのだ。

「Juliaはなぜオブジェクト指向ではないのですか?」そんな問いをよく聞かれる。

本当はよく聞かれるというのは真っ赤な偽り、私自身は一度も聞かれたことはないのだが、オブジェクト指向的に書きたいという声はtwitter等の電脳空間でちらほら目にするのだ。

私はそんな時、このように答えるようにしている。正確には、聞かれたらこのように答えたいと思っている。「Juliaはとりたててオブジェクト指向ではないんですけど、割とオブジェクト指向的なプログラミングもできるんですよ。と言うか、ある意味普通のオブジェクト指向言語を超えているんでるよ」と。なんともキレのない答えだ。

そんな回答をされても困ってしまうだろうし、私もこの回答で何かを伝えられるとはとても思わない。しかし、Juliaがオブジェクト指向的な文法を採用していないからと言って、オブジェクト指向が否定されていると誤解されたらそれはとても悲しい。私はオブジェクト指向が好きだ。一時期は熱狂していると言っていいくらいだった。オブジェクト指向検定というものが存在するとしたら3級くらいはとる自信があるのだ。逆に、Juliaがオブジェクト指向的な文法を採用していないことで、Juliaの評価が下がってしまうとしたら、それも悲しいことだ。Juliaは凄いやつなのだ。そのようなわけで、オブジェクト指向言語とは何だったかを駆け足で振り返りながら、Juliaでいかにオブジェクト指向的プログラミングが可能であるか、さらにはどのあたりが通常のオブジェクト指向言語を超えているか、ということを話そうと思う。

オブジェクト指向とは何か

オブジェクト指向とは何か」

いきなり大それた題目である。オブジェクト指向というのはとんでもないパラダイムなのだ。オブジェクト指向という言葉について万人が合意する定義というのは思いの外少ない。*1

そのような中で、私は次のような立場をとる。オブジェクト指向とは、ソフトウエアを部品の集合体として構成するための手段である、と。正確には、「ソフトウェアを部品の集合体として構築する」という大目標があり、それを実現する一つの手段として「オブジェクト指向プログラミング」と呼ばれるパラダイムがあるのだ。

ソフトウェアは部品の集合体として構成されるべき、という点に関しては、ほぼ万人が合意してくれると考えてもいいだろう。もちろん程度問題はある。書き捨ての小さなプログラムをわざわざ部品の集合体として仕上げる価値はないかもしれない。それだけのコストをかけられないという費用対効果の問題かもしれないし、単に小さなプログラムで理解するのが非常に簡単なのかもしれない。しかし一定規模以上の複雑さのソフトウェアに関しては、部品の集合体として構築することは、おそらく必須である。

となると問題は、いかにすれば部品の集合体として構成できるのか、というところに行き着く。その一つの帰結がSOLID原則と呼ばれるオブジェクト指向の原則である。これはオブジェクト指向の金字塔である。これを満たせばオブジェクト指向設計をしたと胸を張って主張して良い。

抽象化

部品として取り扱えるとはどのようなことだろうか?部品の利用者は、部品の振る舞いのルールは知っておく必要はあるが、部品がなぜそのように振る舞うかの詳細について知る必要がないことをいう。これを「抽象化」と呼ぶ。

オブジェクト指向の細かい話に入る前に、2つの抽象化について語ろう。手続きの抽象化とデータの抽象化である。手続きとデータ、最も素朴なレベルでは、ソフトウェアはこの二種類の構成要素から成る。*2

手続きの抽象化というのはお馴染みの関数のことである。言語によってはメソッドとかサブルーチンとかいう風に呼ばれたりもする。関数とはひとまとまりの手続きを固まりとして取り出したもののことだ。通常、ひとまとまりに呼び出した関数はブラックボックスとして扱うことができる。例えば、引数を二乗して結果を返す関数は、その内部で何を行っているか、利用者側は基本的には知る必要はない。内部で自分自身を掛け算しているかもしれないし、何かのライブラリを使って指数計算をしているかもしれないし、もしかしたら足し算を何回も繰り返しているかもしれない。(ひどい実装だ!)

しかし、そう言った内部での具体的な手続きについてを利用者は知る必要はなく、ただ関数を呼び出せば良い。内部処理をどう変えようが、入力に対する出力さえ変えなければ何をしても良い。これが手続きの抽象化の意味である。

データの抽象化には、いくつかのパターンがあるが、代表的なのは複合データ型と呼ばれるものだ。これは、複数の型を組み合わせて新しい型を作るというものだ。例えば、分数を表すデータをRationalという複合データ型で表し、Rationalは分子と分母を持ちますよ、というようなものだ。そして、このとき、分子と分母を内部的にどう保持しているかは外部からは知る必要がない。内部で二つの変数に保持しているかもしれないし、配列の1番目と2番目に入れているかもしれないし、もしかしたらハッシュテーブルに格納しているかもしれない。

さて、大事なことは複合データ型は定義しただけでは、データの抽象化として不完全だということだ。なぜなら、言語がもともと用意しているオペレータは複合データを取り扱う術を知らないからだ。言語はintとintを足し算する術は知っているが、RationalとRationalを足し算する術は知らない。当たり前だ。Rationalはプログラマが好き勝手に決めたものなので、それにどんな振る舞いを決められるのはプログラマだけだからだ。RationalはRationalと足し算するにはどうすればいいかは、プログラマが用意してあげる必要がある。データの抽象化は、それをサポートする手続きの存在が不可避なのである。そのため、抽象データを導入した時点で、手続きは概念的には少なくとも2層に別れることになる。抽象データを外部から取り扱う手続きと、抽象データと外部を仲介する手続きだ。

  • データ
  • データと外部を仲介する手続き
  • データを外部から取り扱う手続き

データにしろ手続きにしろ、通常具体的なものである。これらに対してどのような機構を導入すれば抽象的に取り扱うことができるのか、というところがポイントになる。概念のレベルでの機構がSOLID原則で、各オブジェクト指向言語が提供している実装のレベルでの機構が、オブジェクト指向三要素(カプセル化、継承、多態)である。

求めるな、命じよ

「求めるな、命じよ。」オブジェクト指向の本質を一言で表すとしたらこれである。上の3つの例で言うと、「データ」自身しか知らない情報に関する処理は「データを仲介する手続き」が実行して必要な情報を提供すべきであり、「データを外部から取り扱う手続き」は、データの中身を知るべきではなく、ただ何かを命じて結果を取得するに留めておくべきと言うことである。

オブジェクト指向三要素

SOLID原則の前に、オブジェクト指向三要素について簡単に述べておこう。ここからしばらくは、静的なクラス指向の言語について語る。C++とか、Javaとか、C#とか、TypeScriptとかだ。その方が当面説明しやすいのだ。動的な言語についてはその後補足する。

カプセル化

カプセル化の特徴は二つである。まず第一に、データの詳細が隠蔽されている。第二に、データが手続きを所有している。この二つの要素は不可分である。

先程から話したように、データがその詳細を完璧に隠蔽すると、他の手続きは手も足も出なくなってしまう。そのため、データが気を許した一部の手続きだけには、自分の詳細にアクセスすることを許可する。その他の手続きとは、その手続きを経由してのみやりとりを行う。外部と疎通するための手続きを持つ、カプセル化されたデータのことをオブジェクトと呼ぶ。カプセルというよりも、触手がウニョウニョでている生き物のようなイメージかもしれない。

そう、データが手続きを持つこと自体は、データの詳細が隠蔽されているがための結果であり、それ自体が目的ではないのだ。

しかし、ここからが少し面白いところだが、データを隠蔽するために、言わば副次的要素として導入された手続きは、結果的に外部から見るとそのオブジェクトの性質を代表するものになっている。オブジェクトの内部でデータがどのような形で保持されていてもいいし、極端な話、オブジェクトの内部にデータはなくても構わない。外部に公開された手続きがそれらしく振る舞うのであれば、それでいい。*3

オブジェクトとは、データと手続きを一体化させたものだとよく言われるが、もっと言うと、データを「振る舞い」に置き換えたものなのだ。

カプセル化を実現するために、通常アクセス指定子(public, privateなど)が定義されている。

継承

継承とは、あるオブジェクトの性質を引き継いだ別のオブジェクトをつくることだ。継承先のクラス(派生クラス)は継承元のクラス(基底クラス)の「特別なケース」として扱われる。なので、基底クラスの変数に、派生クラスのオブジェクトを代入することができる。このことを指して、継承先は継承元と「is a 関係」にあるべきだとも言われる。

カプセル化の部分で語ったように、カプセル化されたデータはもはやデータとして捉えるべきではない。振る舞いの集合体として捉えるべきだ。なので、継承で引き継ぐべきなのも、内部のデータや手続きではなくて外部から見た振る舞いだ。振る舞いを引き継ぐことを意識すると良い継承になることが多い。データと手続きを引き継ぐことを意識すると悪い継承になることが多い。(振る舞いを引き継ぐことを意識した結果、継承元オブジェクトのデータや手続きを使うのは構わない。)

多態

多態は英語で言うとポリモーフィズム。日本語にしろ英語にしろあまり耳慣れない単語だ。(えっ、お前の語学力の問題だって!?)

しかし、多態こそがオブジェクト指向の真髄であるとも言われる。多態とは、同じように見えるオブジェクトを同じように取り扱っても、オブジェクト自身の持つ性質により振る舞いが変化することを言うのだ。この性質は部品化のために大変重要だ。部品を利用する側は、部品の詳細を知らずに済む。部品自身が振る舞いを決めてくれるからだ。

多態を実現するために継承が使われる。クラスを利用する側のコードは、基底クラスの型の変数を宣言する。変数の中には、基底クラスのオブジェクトが渡されたり、派生クラスのオブジェクトが渡されたりする。クラスを利用する側のコードは、具体的に基底クラスのオブジェクトが入っているのか、派生クラスのオブジェクトが入っているかは気にせず、変数に対して何らかの命令を実行する。オブジェクトは自身の属するクラスに従い、適切な振る舞いをする。

オブジェクト指向三要素まとめ

データの抽象化をするためにカプセル化を行い、さらに継承の仕組みを使うことで、多態が実現されるという構図である。カプセル化と多態は性質について言及していて、継承は仕組みについて言及している。

SOLID原則

さあようやくSOLID原則だ。SOLID原則とは英語の頭文字を集めたものである。

  • 単一責任の原則(Single responsibility principle)
  • 開放閉鎖の原則(Open–closed principle)
  • リスコフの置換原則(Liskov substitution principle)
  • インターフェイス分離の原則(Interface segregation principle)
  • 依存性逆転の原則(Dependency inversion principle)

カッコいい言葉が並んでいる。大事な順番に解説していこう。

開放閉鎖の原則(Open–closed principle)

どれが一番大事と言われたらこれである。他の4つに比べてこれは別格である。本当はこれだけ唱えていればいいのであるが、困ったことにこの原則は、それだけ言われても何のことかよくわからないのである。どんな文言なのだろうか。

「ソフトウェアは、拡張に対して開いていて、修正に対して閉じていなければならない」

日本語の勉強からやり直した方がいいのかと自分を責める必要はない。この日本語は少しばかりわかりづらい。もっと説明が必要なのだ。

拡張に対して開いているというのは、ソフトウェアに新たなパターンの振る舞いをさせるときに、既存のコードを解剖して新たな振る舞いを上手いこと組み込む必要がないということである。コードにはすでに振る舞いを拡張するための何らかの機構が用意されていて、振る舞いのパターンを追加したいときには、それを利用できるということだ。

修正に対して閉じているというのは、ソフトウェアの既存のあるパターンの振る舞いを修正するときに、他のパターンの振る舞いに影響を与えることがないということである。

魔法のような構造である。そのような構造はどうやって実現できるのか?それを実現するための手段が残りの原則で述べられている。

依存性逆転の原則(Dependency inversion principle)

非常に重要な原則なのだが、名前があまり良くないと思う。しばしば使われるこちらの言葉の方が的確だ。「抽象に依存せよ。」

ここで言う「抽象」とは、コードを外部から見たときの振る舞いのことである。普通のコードは「詳細」に依存している。詳細とは何か。具体的なコードである。

例えば、与えられた範囲の整数の逆数の和を計算するコードを考えよう。こう言った計算するときのポイントの一つに、絶対値の小さい方から足すというものがある。これは非常に大きな数字と非常に小さな数字を足したときに小さな方の数値が無視されてしまう、情報落ち誤差と呼ばれる現象を回避するテクニックだ。絶対値の小さな方から足していくと、だんだん大きくなっていって、大きな数と足す時にも無視されることがなくなる。

しかし、ループで数値をクルクル足していくプログラムを書いていると、このテクニックを使うのは意外と難しい。実際にどのような値になるかをコードを書くときに推定するのは難しいからだ。単調増加や単調減少であればわかりやすいが、そうでないときにどうするか。その問題を回避してくれるクラスを1つ作ろう。SeriesAccumulator(級数累積器)クラスである。このクラスは与えられた数値の列を内部で保持しておき、号令がかかると絶対値が小さい順に合計してくれるというクレバーなクラスだ。例はTypeScriptで書いたが、TypeScript自体に詳しくなくても支障はないと思う。

class SeriesAccumulator{
    //このクラスは一連の数字を受け取り、その和を返す。
    //和を計算する際に情報落ち誤差を考慮して、絶対値の小さなものから足していく。

    public push(x: number): void{
        this.nums.push(x);
    }

    public sum(): number{
        this.nums.sort(this.compareFunc)//小さい順に並べる
        var s = 0;
        for (var i = 0; i < this.nums.length; i++){
            s += this.nums[i];
        }
        return s;
    }

    private compareFunc(a: number, b: number): number{
        return Math.abs(a) - Math.abs(b)
    }
    private nums: number[] = [];
}

function sumReciprocalSeries(a: number, b: number):  number {
    //逆数の和を求める。開始a, 終了b。bを含む。
    var s = new SeriesAccumulator();
    for (var n = a; n <= b; n++){
        s.push(1/n);
    }
    return s.sum();
}

console.log(sumReciprocalSeries(1, 10));

さて、この例で「詳細」に依存してしまっているのが下記の部分である。

function sumReciprocalSeries(a: number, b: number):  number {
    var s = new SeriesAccumulator(); //ここ!!
    //...
}

何が悪いかというと、逆数の和を計算するという重大な使命を持った上位メソッドが、情報落ち誤差の面倒を見る下位の部品に直接依存してしまっていることだ。もしもSeriesAccumulatorが反旗を翻し、「自分はもうpushメソッドで数字を1つずつチマチマ受け取ったりはしません。配列にまとめて渡してください。」と一方的に通達してきたとしよう。

そうすると哀れ逆数の和を計算する処理は、せっせと配列に数字を詰めてやり、SeriesAccumulatorに差し出す必要がある。やれやれ。しかも来月にもsumメソッドの返り値を文字列にすると言い出している。そのうち、JSONXML詰めて返すと言い出すかもしれない。私がパースしなければならないのだろうか?

これが通常のコードでの依存関係である。上位のメソッドは下位のメソッドを使役している一方、実際には下位のメソッドに振り回される立場でもあるなのだ。どことなく中間管理職の悲哀を思わせる。

依存性逆転の原則はこの関係を良しとしない。

「あなたは部下の自由にやらせすぎている。」銀縁メガネをキラリと光らせて彼は喋る。彼は一流のコンサルタントなのだ。「あなたは上司なのだから、ただ宣言すれば良いのです。あなたのやり方に従うものだけを部下として認めるのだ、と。」

素晴らしいアドバイスを受けたあなたは厳かに宣言をする。

interface SeriesAccumulatorInterface{
    push(x: number): void;
    sum(): number;
}

function sumReciprocalSeries(a: number, b: number, seriesAccumulator: SeriesAccumulatorInterface):  number {
    //...
    var s = seriesAccumulator;
   //...
}

console.log(sumReciprocalSeries(1, 10, new SeriesAccumulator()));

そう、あなたはもはや部下の仕事のやり方を明確に規定した。pushの引数は数値を取ること、sumの結果は数値を返すこと、そう決めたのだ。この流儀に従わない者はもはや部下ではない。そんな奴らはただの馬の骨であり、彼らはあなたの職場に足を踏み入れることすらできない。厳格なコンパイラ警備員が阻止してくれるのだ。

これが依存性逆転の原則である。もともと上位メソッドは下位メソッドに依存していた。しかし、依存性逆転を行った結果、上位メソッドも下位メソッドも、共に同じ約束事に依存するようになった。クラス図で書くと、依存関係の矢印の向きが変わるので依存性逆転の原則と呼ばれる。しかし、逆転させるのが本質ではないように思う。共に共通の約束事(Interface)に従うようになったことが大事なのである。

部品化の重要なポイントに、差し替え可能であることが挙げられる。差し替え可能かどうかを判定する根拠が、Interfaceである。Interfaceを満たしていれば差し替え可能だ。満たしていなければ差し替え不可能だ。

そして、もう一つポイントがある。もともとはクラスの内部で作られていたSeriesAccumulatorが、引数となっていることである。この点も重要だ。いくらInterfaceに従っていても、外部から差し替えるにはそう言った手立てが必要だからだ。引数である必要はないが、何らかの形で外部から与えられる必要がる。

依存性逆転の法則が部品化にとって重要であるというのはこういったわけである。

「抽象に依存せよ。」毎晩寝る前に100回唱えておくと良い。

リスコフの置換原則(Liskov substitution principle)

この原則は難しい。言っていることはそんなに難しくないのだが、この原則との向き合い方が難しいのだ。

リスコフの置換原則とはこうだ。「基底クラスのオブジェクトは常に派生クラスで置き換え可能でなければならない。」言い換えれば、「派生クラスは基底クラスに期待される振る舞いを裏切ってはならない。」

なるほどなるほど。その通りだ。基底クラスの変数を用意し、派生クラスのオブジェクトを差し替え可能な部品として扱うからには、そうでなくてはならない。しかし、何をすればそれが満たせるのだろうか?

しばしば、継承という機能を使っていいかどうかの判定基準は、「is a関係」であると言われている。では、is a 関係を満たしていればそれでいいのだろうか?

例え話をしよう。あなたはとある企業で図形に関するアプリケーションを開発している。ユーザーが何かの図形を定義すると、その図形の面積を高精度に計算することができるのだ。ユーザーは半径5cmの円を定義したり、一辺8cmの正方形を定義したりして、その正確な面積を計算することを楽しんでいる。高精度を売りにしていながらもオプションで円周率を3.14にすることもできるので、小学校の教師からも評判が良い。(競合他社の製品はそのような自由度はないので、小学校のテストの検算には使いづらいのだ。)

さて、あなたのアプリケーションでは、基底クラスとして図形クラスが定義されている。円や正方形は図形の一部である。なので、図形クラスを継承して円や正方形のクラスを作るのは自然な考えだ。円 is a 図形、正方形 is a 図形。いいよね、継承しちゃおうよ。

さて、次なるバージョンアップでの目玉は、図形の周の長さの計算機能の追加だ。マーケティング部門の試算では、これで市場シェアを1.4倍に広げることができるのだ。来月のリリースに向け既に開発は完了しており、最終フェーズのテストが残るのみである。どうやら今回のリリースは平穏に終わりそうだ。

ところが、新製品リリースの噂を聞きつけた天文学者のグループが、惑星の公転軌道は楕円形なので、図形の選択肢にぜひ楕円を加えて欲しいと要望してきたことで状況は一変する。彼らの要望を満たせばまさに天文学的な利益が見込みめると考えた上層部は、チーフ開発者であるあなたの意見を聞くことなく、楕円の追加を約束してしまった。楕円だって図形の一部だし、大丈夫だろう。楕円 is a 図形。いいよね、継承しちゃおうよ。

さて、事情を知ったあなたは大いに困ってしまう。楕円が図形の1つであることに異論はないが、実装上の問題がある。これまで取り扱っている対象の図形は円、正方形、長方形、正三角形だったので、面積と周の長さを計算することは可能だった。楕円であっても面積の計算はそう難しくはない。ところが、楕円の周の長さの計算はとても難しい。上層部の誰かに楕円積分についての知識が少しでもあれば、このような事態には陥らなかったのだが、今更言ってもしょうがないことだ。以後二度とこのようなことが起こらないためにも、あなたが上層部まで上り詰めるしかない。となるとこの難局はむしろチャンスとなるだろう。

とはいえ、高精度の楕円積分の実装を、リリースまでのわずかな時間で完成まで持っていけるかは微妙なところである。テストだって必要だ。思い悩んだあなたは、楕円の周の長さは計算不可能だとして、-1を返すのはどうだろうかと同僚に相談する。幸い天文学者たちは楕円の面積には熱心だが、周の長さには興味がないという。

一人目の同僚はいいんじゃないかと言った。彼の言い分はこうだ。

「周の長さを計算するメソッドの返り値を見たまえ。number型になっているだろう。-1を返したってそれは満たしているわけで、何も悪い事はないじゃないか。」

class Shape{
    calcArea(): number;//面積
    calcCircumferenceLength(): number;//周長
}

もう一人の同僚は激怒した。彼の言い分はこうだ。

「返り値の型など糞食らえだ。誰が周の長さを計算するメソッドを呼び出して返り値をが負の数になることを期待するんだ。」

どちらの意見が正しいだろうか?それは利用者側のコードが決める。もともとShapeクラス共通の決め事として、計算時にうまくいかないことが出てきたら-1を返すものだったとしよう。そうすると利用者側のコードは-1が返ってきたときの対処が記述されているはずなので、楕円の周の長さとして-1を返しても問題なく成立するはずだ。この場合、リスコフの置換原則を満たしていると言える。

一方、これまではそんな動作をさせていないのであれば、-1が返った時の動きは通常は想定されておらず、Shapeクラスを楕円クラスで置き換えてしまっては問題が出るだろう。この場合、リスコフの置換原則を満たしているとは言えない。

つまり、派生クラスの設計者は基底クラスだけを見ていても派生クラスの正当性は判断できず、利用者側のコード、あるいは、利用者に公式に公開しているドキュメント、そのようなもので判断する必要がある。

しかし、我々クラス設計者はクラス利用者の期待をどこまで想定しなければならないのだろうか?極端な話、クラス利用者側の好きにさせていては、完璧にこの要求に応えることは無理だ。だって、振る舞いを変えたいから派生させるのだ。振る舞いの変更がどこまでも正当なものであったとしても、変更前の振る舞いをピンポイントで期待されると、その期待は間違いなく裏切られることになる。

逆に言えば、クラスの利用者とクラスの設計者が期待する範囲について合意しておけば、その範囲内で派生クラスの動きを変更して良い。しばしば、事前条件、事後条件のような言葉で語られる。派生クラスは基底クラスよりも事前条件が狭くてはいけないし、事後条件が広くてはいけない。*4

このような考え方を契約プログラミングと言って、言語やライブラリでサポートされることがある。

クラスの利用者側が基底クラスの動きをピンポイントで期待すると言うのは、事後条件が極めて狭いと言うことであり、派生クラスはその狭苦しい事後条件に縛られることになる。世間一般に公開するAPIだと、この制約は厳しくのしかかって来るかもしれないが、自分のアプリケーションの内部での部品化の話であれば、そこまでガチガチに考える必要はないだろう。とはいえ、どういった点に気をつけるべきかについては知っておくべきだ。

単一責任の原則(Single responsibility principle)

クラスは単一の仕事のみの責任を負うべきであり、複数の仕事をしていてはならないと言うことだ。変更の理由が複数あってはいけないとも言われている。しかし、どちらもちょっと基準としてわかりづらいと思う。意地悪なケースを考えたらどんなに小さなクラスだって仕様変更の理由の2つや3つ考えつくだろう。

私は次の判定基準が分かり易いと思っている。

  • クラスが管理している変数が1つであれば、まず間違いなく単一責任の原則を満たしている。
  • クラスが複数の変数を管理していても、全てのメソッドが全ての変数に関連していれば、この場合もおそらく単一責任の原則を満たしている。
  • クラスが複数の変数を管理していて、この時変数Aのみに関連するメソッドA群と変数Bのみに関連するメソッドB群に分かれたら、単一責任の原則に反している可能性が高い。

実際、多くのケースでは、このようにスッパリと分かれず、変数Aのみに関連するメソッドA群があり、変数Bのみに関連するメソッドB群もあり、変数AとB両方に関連するメソッドAB群もあると言うケースが一番多いと思うが、どのパターンに近いかを知っておけば、クラスを分割する際の目安にはなるだろう。

この原則は、部品化しようと言う発想があれば自然と実現されるだろう。クラスの変更理由など、考えれば考えるほどわからなくなってくるものだ。心構えくらいに思ってもいいかもしれない。

インターフェイス分離の原則(Interface segregation principle)

Interfaceを巨大にしてはならない、小さな複数のInterfaceに分離(分割)しなさい、と言う原則だ。これは正直おまけ感が強い。単一責任の原則を守ると自然と実現されるはずだが、一応言っておいた、くらいのものだ。心構えくらいに思って良い。

SOLID原則まとめ

SOLID原則とあったが、S(単一責任の原則)とI(インターフェイス分離の原則)は心構えくらいに思っておけば良い。重要なのはO(開放閉鎖の原則)とD(依存性逆転の原則)とL(リスコフの置換原則)だった。OLD原則と言っても良いくらいだ。さらにO(開放閉鎖の原則)も目指すべき場所を示しているだけで、実際の方策はD(依存性逆転の原則)とL(リスコフの置換原則)なのであった。D(依存性逆転の原則)を実現するにはInterfaceを定義して差し替え可能な形にすれば良いのだった。L(リスコフの置換原則)を実現するには基底クラスと派生クラスの事前条件と事後条件を明確にすれば良いのだった。つまり、これらと同等のことをJuliaで実現するにはどうすれば良いか、と言うことがわかれば、Juliaでもオブジェクト指向プログラミングが可能になる。次のセクションからはその方策を検討していく。それが結果としてO(開放閉鎖の原則)を満たしていることがわかれば、この記事の目的は達成できたことになる。

動的言語でのSOLID原則

一足飛びにJuliaに入る前に、オブジェクト指向を採用している動的言語について考えよう。例えばPythonである。ここでのSOLID原則はどのような形になるだろうか?ここでは依存性逆転の原則、リスコフの置換原則に焦点を絞ろう。

動的言語での依存性逆転の原則

静的言語では依存性逆転の原則に対して、Interfaceが決定的に重要な役割を果たした。なぜだろうか?

オブジェクト指向的には、クラスの利用者側は自分が用意した変数に具体的にどんなオブジェクトが差し込まれているかは知る必要が必要なく、ただオブジェクトに命じるだけで良いと言うのが理想である。なので、コードを書くときにはできれば呼び出すメソッドだけを指定したい。しかし、静的言語では、原則としてメソッドを呼び出すコードを書いたら、そのメソッドがきちんと型が定義したメソッドであるかどうかを厳しくチェックされる。間違った呼び出しはコンパイルすらできない。それが静的言語の良さであるが、どうしても型に縛られてしまうと言う側面はある。そしてコンパイラが許してくれる最大限に抽象的な型、それがInterfaceである。Interfaceというのは静的言語の世界では極限の抽象化ではあるが、それは静的な型チェックの制約が入った世界の中での極限である。

動的言語でははるかに自由な世界である。何と言ってもコーディング時の型チェックが存在しないのだ。そのため、PythonRubyオブジェクト指向言語でありながら、言語自身はInterfaceという機構を用意していない。しかし、だからと言って動的言語では依存性逆転の原則が適用できないわけではない。思い出そう。「抽象に依存せよ」だ。「Interfaceに依存せよ」ではない。

先ほどの逆数の和を求めるケースのPython版は次のようなコードになるだろう。

class SeriesAccumulator:
    """このクラスは一連の数字を受け取り、その和を返す。
    和を計算する際に情報落ち誤差を考慮して、絶対値の小さなものから足していく。
    """
    def __init__(self):
        self.__nums = []

    def push(self, x):
        self.__nums.append(x)

    def sum(self):
        sorted_nums = sorted(self.__nums, key=abs)#小さい順に並べる
        s = 0
        for num in sorted_nums:
            s += num
        return s

def sum_reciprocal_series(a, b, series_accumulator):
    """逆数の和を求める。開始a, 終了b。bを含む。"""    
    s = series_accumulator
    for n in range(a, b+1):
        s.push(1/n)
    return s.sum()

print(sum_reciprocal_series(1, 10, SeriesAccumulator()))

TypeScript版との目立った違いは、Interfaceという定義がないことくらいである。実際にはInterfaceに相当するものは存在する。ただ明示的には記述されていないだけだ。それはsum_reciprocal_seriesの中に存在する。そう、series_accumulator変数は、push(x)、sum()と呼べる必要があるということだ。この変数にその制約に違反するオブジェクトを渡してはいけない。渡すと実行時にエラーになる。Interfaceは利用者側のコードに内在している。

静的言語では、依存性逆転の原則を適用する(というよりも抽象に依存する)ためには2つのプロセスが必要だった。Interfaceの定義と、外部からの注入である。動的言語ではInterfaceの定義は必要ない。外部から注入すればそれで良い。外部から渡されたものが何なのかは知らないが、とにかく命令すれば良いのである。そうして、オブジェクトは自身に定義されたメソッドに従い、適切に振る舞うのだ。

動的言語でのリスコフの置換原則

動的言語では、コーディング時に変数に型を指定する必要がないおかげで、継承という機構に頼ることなく多態を実現することができる。オブジェクトに指定されたメソッドが定義されていたらそれで良い。これをダックタイピングと呼んだりする。「もしもそれがアヒルのように歩き、アヒルのように鳴くのなら、それはアヒルである。」

そのため、動的言語においては、継承というものが果たす役割は静的言語と比べてずっと小さい。継承の説明で述べたように、振る舞いを引き継ぐことを意識すると良い継承になることが多く、データと手続きを引き継ぐことを意識すると悪い継承になることが多い。動的言語では前者を実現するために継承を使う必要はないので、使うとしたら後者になる。別にうまく使えば後者の目的で使っても悪い事はないのだが、データや手続きを共有するだけであれば委譲という別の仕組みもあり、そちらの方が好まれる傾向にある。

ただし、リスコフの置換原則は継承という文脈で語られてはいたものの、本質的にはクラスの利用者側の期待とクラスの振る舞いに関する原則である。そのため、動的言語でも依然としてリスコフの置換原則に相当する概念は存在する。ただし、クラスの利用者側のコードに変数の型宣言のようなものはないので、必然的に言い回しが変わることになる。

リスコフの置換原則の静的言語版は「派生クラスは基底クラスに期待される振る舞いを裏切ってはならない。」であった。これを動的言語版に置き換えると、「利用されるオブジェクトは、利用者側のコードが期待する振る舞いを裏切ってはならない。」というごく当たり前の文言になる。取り立てて原則と言うほどのこともない。

継承の役割が縮小してしまった以上、リスコフの置換原則の重要度も下がってしまうのは仕方のない事だ。

動的言語でのSOLID原則まとめ

静的言語で考えていた時と比べると、動的言語ではSOLID原則の見え方が随分と変わって来る。

  • 単一責任の原則、インターフェイス分離の原則

    • これらは部品化を念頭におけば自然とこの方向に向かうというものである。
  • リスコフの置換原則

    • 取り立てて言うほどのものでもなくなった。

そうなると、もはや開放閉鎖の原則を実現するために必要なのは、依存性逆転の原則だけということになる。

  • 依存性逆転の原則
    • 差し替えたいオブジェクトは内部で作るのではなく外部から渡すべし。(でなければどうやって差し替えるのか?)

この依存性逆転の原則だけで、開放閉鎖の原則を満たせるのだろうか?

  • 開放閉鎖の原則
    • 拡張に開いており、修正に閉じているべし

すなわち、外部から渡す口を作ってあげておけば、新たな振る舞いをするオブジェクトを渡す事は簡単であり(拡張に開いている)、それぞれのオブジェクトはそれぞれのクラスで閉じた変数とメソッドを持っているので、あるクラスのメソッドの修正が他のクラスのメソッドの修正に影響を与える事はない(修正に閉じている)。

何と、動的言語でのSOLID原則と言うものは、結局のところ、部品化されたオブジェクトを自由に差し替えられるようにすべし、と言うところに帰着してしまったのである。

しかし、考えてみれば当たり前かもしれない。プログラムを部品化して、振る舞いは部品に任せると言う事は、振る舞いが決まるのはコーディング時ではなく実行時になると言う事である。そうなるとコーディング時に色々決めなければならない静的言語では継承やInterfaceなどの工夫が必要だった。実行時に全てが決まる動的言語はそのような仕組みが不要なので、結果気にすることが少なくなり、シンプルな原則になっていくのは当然のことかもしれない。

Juliaでのオブジェクト指向

いよいよJuliaだ。ここまで長々と語ってきたように、オブジェクト指向動的言語はとても相性が良い。と言う事は、動的言語であるJuliaはきっとオブジェクト指向と相性が良いはずなのだ。

まず、オブジェクト指向三要素について話そう。Juliaにはオブジェクト指向三要素はあるのだろうか?答えから言うと、カプセル化はない、継承もない、多態はある。

カプセル化がないと言うのは致命的に思える。この記事の出発点が、データの抽象化から始まっている。カプセル化がなくて大丈夫だろうか?大丈夫だ。なぜならカプセル化に関しては、必殺「見ないふり」ができるからだ。公開されていたって良いじゃないか。必要のない時は見なければ良いだけの話だ。

Juliaが用意しているデータ抽象の仕組みは構造体だ。構造体にはprivateやpublicのようなアクセス指定子はない。言ってしまえば全部publicだ。しかし、データ抽象は依然として有用なアイデアなので、公開したくない要素を見分けるために、名前の先頭にアンダースコアをつけることにする。そして、アンダースコアの付いている要素にアクセスできるのは一部の関数に制限する。自分で規律を守って制限するのだ。

継承がないのは問題にならない。動的言語で継承があまり重要でないのは見た通りだ。

多態に関しては、多重ディスパッチという非常に強力な仕組みが提供されている。多重ディスパッチについては後ほど説明するが、この機能を採用したことにより通常のクラスベースのオブジェクト指向を一部超えているのだ。

Juliaのカプセル化

簡単な例を見よう。今回の例では多態はまだ登場しない。カプセル化のイメージだ。2次元の点を表す構造体Point2Dを考える。普通に考えたら内部にx, yと言う変数を保持するところだが、どうやらこの構造体は配列で保持しているようだ。配列の1番目がx, 2番目がyである。何か実装上の観点からそのような保持をした方がいいと言う判断になったのだろう。

しかし、2次元の点と言うものを考えたときに、内部で配列を持っていると言うことはなかなか予期できない。こういった内部情報に依存したコードが至る所に散らばっていたら、内部でのデータの保持のやり方を変えるときに多くの処理が影響を受けてしまう。そこで、内部情報にアクセスできる関数は一部に制限し、その他の関数は全てその関数を経由してデータを取得するようにしよう。

struct Point2D
    _arr #公開したくない変数。配列の1番目にxを、配列の2番目にyを持っている。
end

#get_x, get_yは詳細を覆い隠している
function get_x(pt)
    return pt._arr[1]
end

function get_y(pt)
    return pt._arr[2]
end

function to_string(pt)
    #仮に内部データの持ち方を変えてもこの関数は影響を受けない
    x = string(get_x(pt))
    y = string(get_y(pt))
    return "(" * x * ", " * y * ")"
end

pt2D = Point2D([1, 2])
println(to_string(pt2D)) # (1, 2) と表示される。

こうすることで、Point2Dの内部変数を配列から辞書に変えたとしても、影響を受けるのはPoint2Dを作る部分と、get_x, get_y だけであり、to_stringは影響を受けなくなる。

Juliaの多態

次はJuliaの多態の仕組みを見ていこう。ちなみに、まだ多重ディスパッチは登場しない。

先程の例に下記のようなコードを追加しよう。2次元の点を表すPoint2Dの豪華版、DeluxPoint2Dだ。どのあたりがDeluxなのかと言うと、to_stringしたときに「耳あて」がつくのだ。

struct DeluxPoint2D
    _arr
end

function to_string(pt::DeluxPoint2D)
    x = string(get_x(pt))
    y = string(get_y(pt))
    return "*(" * x * ", " * y * ")*"
end

deluxPt2D = DeluxPoint2D([1, 2])
println(to_string(deluxPt2D)) # *(1, 2)* と表示される。おしゃれな耳あてがついている。

まず目を引くのは、get_x, get_yを定義していないことだ。これはダックタイピングの一例だ。Point2Dのget_x, get_yでは、型指定をしていなかった。そのため、DeluxPoint2Dではget_x, get_yを定義していないのだが、Point2Dのget_x, get_yが適用され、たまたま_arrという変数を内部に持っていたのでうまくいったのである。

次に目を引くのが、to_stringである。このケースではDeluxPoint2D用に特化された関数として定義されている。このため、Point2Dにはない特典をDeluxPoint2Dにはつけることができているのである。このように同等の関数呼び出しでもパラメータの型に最も特化された関数が選択されると言う仕組みで、多態が実現されている。

なお、余談になるが、例では上のようにしたが、私は普通このようには書かない。Point2Dの内部構成を変更したときに、DeluxPoint2Dも同様に変えなければならないことを忘れるからである。このようなケースでは委譲を使う。*5

struct DeluxPoint2D
    _pt2D::Point2D #内部でPoint2Dを保持する
end

function DeluxPoint2D(arr)
    DeluxPoint2D(Point2D(arr))
end

#Point2Dと同じでいい仕事は全部Point2Dに丸投げ
function get_x(pt::DeluxPoint2D)
    return get_x(pt._pt2D)
end

function get_y(pt::DeluxPoint2D)
    return get_y(pt._pt2D)
end

#変えたい動作だけ定義する。
function to_string(pt::DeluxPoint2D)
    x = string(get_x(pt))
    y = string(get_y(pt))
    return "*(" * x * ", " * y * ")*"
end

deluxPt2D = DeluxPoint2D([1, 2])
println(to_string(deluxPt2D)) # *(1, 2)* と表示される。

Juliaの多重ディスパッチ

いよいよJuliaの真骨頂、多重ディスパッチを見ていこう。ディスパッチとは処理を振り分けると言う意味だ。多重ディスパッチとは、複数の引数オブジェクトの型に従って使われるメソッドが決定されることだ。どういうことだろうか?

いわゆるオブジェクト指向の書き方「object.method」形式だと、使用されるメソッドはオブジェクトで決まる。object.method(...)という形式の記述は、実質的にはmethod(object, ...)と言う関数呼び出しと等価である。そのため、第一引数という単一の引数オブジェクトのみに依存してメソッドが挿しかわるといることで、単一ディスパッチと呼ぶのだ。

多重ディスパッチがどのように働くかを見るのはコードを見てもらうのが早いだろう。先程Point2DとDeluxPoint2Dを考えたが、これに点を移動させるメソッドmoveを作るようにしよう。さて、Point2Dはmoveで受け取った引数の通りにそのまま動くが、Delux版は何とmoveで受け取った引数の1.1倍の距離を動くのだ。とんだじゃじゃ馬だ。さすがデラックスの名に恥じない。

これではまだ単一ディスパッチだ。多重ディスパッチにするにはもう1つ引数がいる。moverというものに登場してもらうようにしよう。moverは移動距離dx, dyを持つ。moverにも普通のmoverとデラックス版moverがある。普通のmoverは特に何の作用も及ぼさないが、デラックス版のmoverは、普通のPoint2Dと組み合わさると移動距離を1.2倍にしてくれて(とんでもないじゃじゃ馬だ!)、さらにデラックス版のPoint2Dと組み合わさるとその力はさらに増幅され、移動距離はなんと729倍にもなるのだ。(すごいぞ!)

このバグとしか思えない仕様を実装してみよう。

struct Point2D
    _arr 
end

struct DeluxPoint2D
    _arr
end

struct Mover
    dx
    dy
end

struct DeluxMover
    dx
    dy
end

#get_x, get_y, to_stringは先ほどと同じなので省略

function move(pt, mover)
    new_x = get_x(pt) + mover.dx
    new_y = get_y(pt) + mover.dy
    return Point2D([new_x, new_y])
end

function move(pt::DeluxPoint2D, mover::Mover)
    new_x = get_x(pt) + mover.dx * 1.1
    new_y = get_y(pt) + mover.dy * 1.1
    return DeluxPoint2D([new_x, new_y])
end

function move(pt::Point2D, mover::DeluxMover)
    new_x = get_x(pt) + mover.dx * 1.2
    new_y = get_y(pt) + mover.dy * 1.2
    return Point2D([new_x, new_y])
end

function move(pt::DeluxPoint2D, mover::DeluxMover)
    new_x = get_x(pt) + mover.dx * 729
    new_y = get_y(pt) + mover.dy * 729
    return DeluxPoint2D([new_x, new_y])
end

pt2D = Point2D([1, 2])
pt2D = move(pt2D, Mover(1, 1))
println(to_string(pt2D)) # (2, 3) 

deluxPt2D = DeluxPoint2D([1, 2])
deluxPt2D = move(deluxPt2D, Mover(1, 1)) #1.1倍
println(to_string(deluxPt2D)) # *(2.1, 3.1)* 

pt2D = Point2D([1, 2])
pt2D = move(pt2D, DeluxMover(1, 1)) #1.2倍
println(to_string(pt2D)) # (2.2, 3.2)

deluxPt2D = DeluxPoint2D([1, 2])
deluxPt2D = move(deluxPt2D, DeluxMover(1, 1)) #729倍
println(to_string(deluxPt2D)) # *(730, 731)*

moveという関数が同じ数の引数でいくつも定義されている。それぞれ特定のデータ型と結びついており、どのデータ型に紐づくメソッドが選択されるかは、実行時の型情報に従いJuliaが決める。

静的言語でいうところのオーバーロードに似た印象を持つかもしれない。しかし、静的言語の場合は通常は変数の型に依存してメソッドが決定される。変数に入っている値の型ではないのだ。なので、いくら基底クラスと派生クラスでオーバーロードが定義されていても、基底クラスの変数を渡している限り、派生クラスのオブジェクトが代入されていても、派生クラスのメソッドは呼ばれない。*6

一方、動的言語では通常オーバーロードというものは存在しない。引数に型を指定することがないからだ。

そのため、静的言語にしろ動的言語にしろ、引数の型に応じて動作を変えようと思うと、引数の実行時の型を読み取ってメソッド内部で条件分岐をするしかない。これは機能を拡張したいときに(派生型を増やしたいときに)既存のメソッドの中身をいじることになり、開放閉鎖の原則に反する。

多重ディスパッチは動的言語でありながら必要に応じて型指定できるJuliaならではの機能だと言える。(Julia以外にこの機能を実装している代表的な言語がCommon LispのオブジェクトシステムCLOSで、これもまた動的な言語だ。)

なお、クラス指向の静的なオブジェクト指向言語でも、二つのクラスの実行時の型に応じて呼び出しメソッドが決まる、二重ディスパッチは可能である。これはVisitorパターンと呼ばれるやり方で、良く確立されたやり方なのだが、欠点がある。

1つ目は、実装がややこしいという点である。二重ディスパッチはそれでも頑張ればついていけないことはないが、三重以上のディスパッチは煩雑で手に負えないと思う。(実際にはやったことがないので何とも言えない。原理的には可能だとは思うが。)

2つ目は、クラスの拡張の方向性が非対称であるという欠点がある。こちらはより深刻な問題で、2つのクラスの一方しか拡張に開いていないのだ。

多重ディスパッチはどちらの問題も存在しない。メソッドは必要な型の組み合わせ分だけ、ただ並列に並べていけばよいのだ。

Juliaでのオブジェクト指向まとめ

Juliaはクラス指向のオブジェクト指向文法を採用していない。抽象データが手続きを所有しているという機構は確かに強力で直感的なのだが、制約も大きく複雑になる。そのため、あえてデータは公開状態にしておき、手続きとデータは引数を通じてゆるく関連させておくにとどめておく。そうした上で、動的言語の利点を生かし、適切なメソッドが選択される多重ディスパッチの機構を提供しておく。結果としてJuliaが提供している機構は驚くほどシンプルだ。

その上で私は、データをある程度は隠蔽しておくことを推奨する。構造体の変数が、その構造体の公開インターフェスとしてふさわしいとは限らないからだ。相応しくないと判断した場合は、先頭にアンダースコアをつけるなどの命名規則で隠蔽する意図があることを明確にしておき、限られたメソッドを通じてしかアクセスさせないように意識する。そのメソッドが構造体の変数の代わりに公開インターフェースとして構造体の振る舞いを宣言する働きを担う。

さらに、SOLID原則を実現するべく、次の点に気をつける。メソッドの内部で直接構造体をつくっている箇所があったら、差し替えの必要がないか検討すること。もしも差し替える必要が今後出てきそうであれば、引数などの形で外部から与えること。TypeScript, Pythonで例示した、逆数の和を計算する処理のJulia版を書いておく。Python版とは特によく似ている。object.method形式になっているかどうか、というくらいの違いである。

struct SeriesAccumulator
    _nums
end

function SeriesAccumulator()
    return SeriesAccumulator([])
end

function push!(accum::SeriesAccumulator, val)
    Base.push!(accum._nums, val)
end

function sum(accum::SeriesAccumulator)
    sort!(accum._nums, by= x->abs(x))
    s = 0
    for num in accum._nums
        s += num
    end
    return s 
end

function sum_reciprocal_series(a, b, seriesAccumulator)
    """逆数の和を求める。開始a, 終了b。bを含む。"""    
    s = seriesAccumulator
    for n in a:b
        push!(s, 1/n)
    end
    return sum(s)
end

print(sum_reciprocal_series(1, 10, SeriesAccumulator()))

以上が、私の話したかった内容である。Juliaは決してオブジェクト指向を採用していないわけではない。それどころか、現行存在する最も強力なオブジェクト指向言語の1つと言ってもいい。その実現のために採用した文法が、一見オブジェクト指向らしくは見えないというだけの話なのだ。Juliaでのめくるめくオブジェクト指向ライフをぜひ楽しんでいただきたいと思う。

おまけ

どうしても、どうしてもobject.method形式で呼びたいのだという人のために、このセクションを設けた。先ほど言ったように、object.method形式はJuliaの武器である多重ディスパッチが発揮できない。そのため、あまりおすすめはしない。しかし、object.method形式での呼び出しが確かに自然に思えるケースというのもある。そのため、Juliaでobject.method形式で呼ぶやり方も紹介しておこう。

Point2Dを再度例に出そう。今回は先程の仕様は忘れる。Point2Dはmoveという処理で素直に指定された距離だけ移動する。1.1倍などしない。

さて、Point2Dは内部の変数としてx, yを持つとしよう。さらに、get_x, get_y でx, yの値を取得でき、to_stringで(1, 2)のような形式で文字列を返し、moveメソッドで移動する。一気に実装してしまおう。

struct Point2D
    get_x
    get_y
    to_string
    move
end

function Point2D(x, y)
    function get_x()
        return x
    end

    function get_y()
        return y
    end

    function to_string()
        str_x = string(x)
        str_y = string(y)
        return "(" * str_x * ", " * str_y * ")"
    end
    
    function move(dx, dy)
        return Point2D(x + dx, y + dy)
    end

    return Point2D(get_x, get_y, to_string, move)
end

pt = Point2D(1, 2)
println(pt.get_x()) # 1
println(pt.get_y()) # 2
pt = pt.move(1, 1)
println(pt.to_string()) # (2, 3)

これは一体何をやっているのか?

これを理解するにはクロージャという機構を理解する必要がある。

クロージャ

クロージャというのは、分かってしまえば大したことはないのだが、分かるまでは全くわからないという稀有な概念だ。なので、この文章を読んでわからなくても自信を喪失する必要はない。なるべくわかりやすく説明しようとは思うが、自分自身がわからなかった頃の気持ちを忘れている気がするので、うまく説明できるかはわからない。

クロージャというのは、一言で言うと「状態を持った関数」のことだ。わからないね?わかったと言ってくれた人は元から知っている人だろう。ここでいきなりクロージャのサンプルを見せたりはしない。順を追って説明していこう。

さて、Juliaでは関数は第一級のオブジェクトだと言われる。これはどういう意味かというと、関数を値と同じように、変数に代入したり、引数に渡したりできるということだ。一部の言語では関数は二級市民だ。彼らはただ定義されるだけであり、数値や文字列ように変数に代入することはできない。しかし、Juliaでは可能なのだ。

次の例では、関数squareを変数fに代入している。fに引数を与えると、squareの関数が実行されたのと同じことになる。

function square(x)
    return x * x
end

f = square
println(f(3)) #9と表示される

次の例は、関数を引数として渡している。与えられた関数を、引数に2回適用する。

function double_apply(f, x)
    return f(f(x)) #fをxに2回適用している
end

println(double_apply(square, 3)) #squareが2回適用されて81と表示される。

あまり役に立たない例を出したが、関数を変数に代入したり引数に渡すことのイメージは掴んでもらえたと思う。関数を引数として渡せることは非常に強力な抽象化となる。例えば、積分の計算を行うときに、積分計算のアルゴリズム被積分関数を分けることができるのだ。

では次に、関数を返す関数というものを考える。関数は変数に代入したり引数にできたのだ。当然返り値にもできる。次の例は、与えられた引数が0以上だったら「引数を2乗する関数」を、負だったら「引数を2倍する関数」を返す関数だ。何の役に立つかはよくわからない。

function make_func(val)
    function square(x)
        return x * x       
    end

    function double(x)
        return 2 * x
    end

    if (val >= 0)
        return square
    else
        return double
    end
end

f = make_func(1)
println(f(3)) #fにはsquareが入っているので9

g = make_func(-1)
println(g(3)) #gにはdoubleが入っているので6

さて、次のステップでいよいよクロージャの登場だ。まず、クロージャは関数を返す関数によって作られる。そして、返される関数は変数を保持できるのだ。次の例を見て欲しい。

これがクロージャの定義のコードだ。make_counterは関数だ。内部にローカル変数countと、関数count_upを持っている。make_counterは関数count_upを返す。

主役は返される関数count_upだ。これがクロージャである。count_upがただの関数ではなくクロージャと呼ばれる所以は、count_upは、外側の変数countを参照していることにある。ひとまず定義側の説明はここまでだ。

function make_counter()
    count = 0
    function count_up()
        count += 1
        return count
    end
    return count_up
end

次にこれがクロージャの利用者側のコードだ。変数counterにクロージャが入っている。counter何度も呼び出すと、数値がカウントアップすることがわかる。

counter = make_counter()
println(counter()) # 1
println(counter()) # 2
println(counter()) # 3

明らかにcounter変数に入っている内部関数count_upは、make_counterのローカル変数countの値を保持している。通常ローカル変数は、関数を抜けると破棄される。しかし、countは内部関数count_upにより参照されている。この場合、関数を抜けても破棄されないようになっているのだ。このように、自身の周囲の環境を保持している関数のことをクロージャと呼ぶ。

私はなかなかクロージャが理解できなかったが、むしろ理解することを拒否していたのかもしれない。ローカル変数というのは見ていて安心する変数だ。それは関数を抜けたらきれいさっぱり忘れて良いからだ。その概念を根底から覆すのがクロージャなのだ。そのようなわけで私は最初は不安に思っていたが、慣れたらただの関数なのかクロージャを返す関数なのかはすぐにわかるようになるので、あまり気にならなくなる。

大して代わり映えのしない例だが、もう1つクロージャの例を載せておこう。先ほどの例との違いは、クロージャを作る関数とクロージャ自身がそれぞれ引数をとっていること、returnを明示していないことくらいである。Juliaの文法で、returnを明示していない場合には最後の式が返り値となるので、この場合、関数adderが返り値となる。

function make_adder(init_value)
    sum = init_value
    println("adderが生成されました。初期値は" * string(init_value) * "です。")

    function adder(val)
        sum += val
        println(string(val) * "が加算されました。合計は" * string(sum) * "です。")
    end
end

a = make_adder(100) #adderが生成されました。初期値は100です。
a(1) #1が加算されました。合計は101です。
a(2) #2が加算されました。合計は103です。
a(3) #3が加算されました。合計は106です。

さて、クロージャは何かに似ていないだろうか?そう、クラスの定義にとてもよく似ているのだ。Pythonで似たようなクラスを書くとするとこうなるだろう。

class Adder:
    def __init__(self, init_value):
        self.__sum = init_value
        print("adderが生成されました。初期値は" + str(init_value) + "です。")

    def add(self, val):
        self.__sum += val
        print(str(val) + "が加算されました。合計は" + str(self.__sum) + "です。")
    
a = Adder(100)
a.add(1)
a.add(2)
a.add(3)

そっくりだ!生き別れの兄弟と言ってもいいだろう。クラスではメンバ変数をメンバメソッドが共有する。クロージャでは内部変数を内部メソッドが共有する。このクロージャの仕組みを使ってクラスもどきを作ろうというのである。

もう一度、最初の例に戻ろう。まず最初に構造体を定義している。ここで普通の構造体と違うのは、ここに定義されているのは構造体の内部データではなく、構造体の振る舞いを定義するメソッド名だということである。データはクロージャで管理するので構造体には入ってこない。通常は構造体のデータには「構造体名.変数名」でアクセスするが、構造体の変数に入っているのがデータではなくクロージャになるのだ。

struct Point2D
    get_x
    get_y
    to_string
    move
end

これがPoint2Dのコンストラクタである。コンストラクタの内部で4つのクロージャを定義している。このクロージャはローカル変数を持たず引数の情報のみを保持している。構造体が作られた時に与えられた引数の値を保持しておき、利用するのである。

function Point2D(x, y)
    function get_x()
        return x
    end

    function get_y()
        return y
    end

    function to_string()
        str_x = string(x)
        str_y = string(y)
        return "(" * str_x * ", " * str_y * ")"
    end
    
    function move(dx, dy)
        return Point2D(x + dx, y + dy) #moveメソッドはオブジェクト自身の情報は変更しない。新しいオブジェクトを作る。
    end

    return Point2D(get_x, get_y, to_string, move)
end

そしてこれがPoint2Dの利用者側のコードである。あたかもPoint2Dというクラスを定義したかの如くpt.get_x()などとメソッド呼び出しができている。

pt = Point2D(1, 2)
println(pt.get_x()) # 1
println(pt.get_y()) # 2
pt = pt.move(1, 1)
println(pt.to_string()) # (2, 3)

moveについて注文がつくかもしれない。今回作ったクロージャはJuliaの構造体のデフォルトに倣い、不変にしている。そのため、moveを呼び出してもオブジェクト自身の情報は何も変わらず、新しいオブジェクトを作って返却しているのだ。

しかし、通常のオブジェクト指向では、オブジェクトが管理する変数の情報は可変であるため、pt = pt.moveのように書く必要はない。ただ、pt.moveのように呼び出せば良いのだ。どうせ似せるのであればそこまでした方がいいかもしれない。破壊的メソッドであることを明示するためメソッド名はmove!としておく。

function Point2D(x, y)
    _x = x
    _y = y

    function get_x()
        return _x
    end

    function get_y()
        return _y
    end

    function to_string()
        str_x = string(_x)
        str_y = string(_y)
        return "(" * str_x * ", " * str_y * ")"
    end
    
    function move!(dx, dy)
        _x += dx
        _y += dy
    end

    return Point2D(get_x, get_y, to_string, move)
end

pt = Point2D(1, 2)
println(pt.get_x()) # 1
println(pt.get_y()) # 2
pt.move!(1, 1)
println(pt.to_string()) # (2, 3)

もはや呼び出し側のコードは普通のオブジェクト指向言語としか思えないだろう。Juliaは何だって許容してくれるのだ。最高だ。Juliaで思い思いのオブジェクト指向ライフを楽しんで欲しい。

*1:この辺りの議論に興味のある方は次のリンクを参照してほしい。http://practical-scheme.net/trans/reesoo-j.html

*2:Lisperはコードはデータだと口を挟んでくるかもしれないが、その話は以前やったのと、今回はそういう話をしたいわけではないので無視しておこう。

*3:内部にデータを持たずにオブジェクトらしく振る舞うことができるかどうか、という議論に興味がある方は、「チャーチ数」という言葉で検索してみてほしい。

*4:楕円の例で言うと、Shapeクラスがこれまで計算値として0以上の数を返すと言う事後条件だったところに対して、楕円クラスは負の数も返すと言うより広い事後条件にしてしまったのだ。

*5:何となく、マクロを使ったらもっとエレガントに解決できる気もするが、深入りしない。

*6:これが静的言語の本質的な制約なのか、単に仕様をそう決定しているだけの問題なのか、私にはわからない。ただ、クラスに属するメソッド呼び出しを動的に切り替えるには、C言語でいうところの関数ポインタのような機構で実現できそうだと想像できるが、関数の引数の部分まで動的に選択されるような機構をコンパイル時に作るのは想像できない。

Juliaのマクロは凄かった。Lispは果たして勝てるのか?

前回の記事で、LispとJuliaのマクロを比較した。

muuuminsan.hatenablog.com

この記事の結論として、私は次のように宣言をした。

Juliaはそもそも関数定義の形とマクロ呼び出しの構文が全然違うので、うまくいかない。真似て書くなら下記のようになるが、実装をどうすればいいか私にはわからないし、呼び出し側も普通の関数定義とは違った、ぎこちないものになるだろう。

これがLispの誇る究極の同図象性だ。S式だからこそ、このような芸当が自然にできるのだ。しかし、S式に採用すると、それはLispになってしまうのではないか?これがLisp族とマクロの切っても切り離せない関係なのだ。

声高らかに、天高らかに発した宣言だったが、実際にはこれは誤りであった。私はJuliaの秘められたパワーに気づいていなかったのである。

前回の判定根拠

私はまず、Lispのマクロとして次のようなものを提示した。

(defmacro defun-with-log (funcname args &body body)
  `(defun ,funcname ,args
     (progn
       (start-log (string ',funcname))
       ,@body
       (end-log (string ',funcname)))))

このためにこんな感じの補助関数を用意している。*1

(defun strconc (&rest lst)
  (apply #'concatenate 'string lst))

(defun start-log (funcname)
  (print (strconc funcname ": write start log")))

(defun end-log (funcname)
  (print (strconc fucname ": write end log")))

このマクロはこのように呼び出す。関数定義とほとんど変わらない見かけだ。

(defun-with-log add (a b)
  (print (+ a b)))

これは、次のようにマクロ展開される。

(defun add (a b)
  (progn
    (start-log (string 'add))
    (print (+ a b))
    (end-log (string 'add))))

これにより、通常の関数呼び出しのように、(add 2 3)と呼び出すだけで、ログ出力を前後に挟むことができるのだった。

(add 2 3)
;次のような出力が得られる。
"ADD: write start log"
5
"ADD: write end log"

なお、実際のところはこのdefun-with-logマクロは誤っている。それというのは、通常Lispでは一番最後の式が関数の戻り値となるのだが、このマクロは本来の関数の戻り値を消してしまい、end-log関数の戻り値で上書きしてしまうのである。そのため、実はあまり実用的ではないのだが、ここはわかりやすい例ということで許してほしい。都合の悪いことには目を瞑って突っ走ることが必要な時も人生にはあるのだ。なお、この欠点を修正した例は sin(@hyotang666)氏や黒木玄(@genkuroki)氏が例示されている。

さて、話を戻すと、前回の記事で私は、これと同等のマクロはJuliaでは書けないだろうと言った。その根拠を次に示そう。

そもそも、なぜLispは、こんなにも上手く書けるのだろうか?私はその理由として同図象性を挙げた。すなわち、マクロの呼び出しの形式と関数定義の形式が同じS式であり、S式から要素を取り出すのも、S式を組み立てるのも非常に簡単であるため、このようなことが可能になっていると言ったのである。

まず、呼び出し形式は次のようになっている。これはdefun-with-logというマクロ呼び出しであり、第一引数にadd、第二引数に(a b)、第三引数に (print (+ a b)) を渡している。

(defun-with-log add (a b)
  (print (+ a b)))

大事なのは、マクロの呼び出しを書いたら、それがあたかも普通の関数定義のように「見えた」、というところである。これらがどちらも「スペースでの分かち書きというS式のルール」を採用していることにより、このような現象が発生している。

そして、受け取った引数を元に、逆クォートとカンマを使って簡単に関数を組み上げている。これもLispのマクロの強みの一つである。だが、これはJuliaでも同じくらい得意とする分野なので、これはLisp固有の強みではない。

(defmacro defun-with-log (funcname args &body body)
  `(defun ,funcname ,args
     (progn
       (start-log (string ',funcname))
       ,@body
       (end-log (string ',funcname)))))

一方、Juliaの方はどうか?

Juliaで書くとしたらこんな風になるだろうと書いた。

macro function_with_log(funcname, args, body...)
   #???
end

そして、この形式はJuliaのマクロの呼び出し形式と全く違うので、上手くいかないだろうと言った。なぜなら、私は、マクロ呼び出しを書くとするとこんな風になるだろうと思っていたからだ。

func = quote
    function add(a, b) 
        println(a + b) 
    end
end

funcname = func.args[1].args[1]
args = func.args[1].args[2:end]
body = func.args[2:end]
@function_with_log(funcname, args, body) #カンマで分けて渡す必要があるんだから、当然こうしないとね

これは凄くぎこちないやり方に思える。こんな使い方を誰が好んでするだろうか?

結局、Lispマクロみたいに使えないのは、関数定義が「構文要素がスペース以外にも括弧やらカンマやら区切られている」一方で、マクロ呼び出しは「カンマ区切りである」という非対称が原因のためであると判断したのだ。しかしこれは誤りだった。

Juliaのマクロ呼び出し構文

さて、私の誤りを指摘して下さった方の一人があんちもん2(@antimon2)氏である。この方は、私が当初書こうとしてかけなかったJulia版withlogマクロを作成してくださった。先ほど紹介した黒木氏のマクロも有用だが、あんちもん2氏の形式の方が比較として近いので採用させていだだく。

コードを抜粋させていただく。下記がシンプル版と呼ばれていたwithlog_simpleである。もう一つ、MacroToolsというものを使うコードも例示されていたが、こちらの方が教育的だ。

macro withlog_simple(ex)
    if Meta.isexpr(ex, :function)
        s = string(ex.args[1].args[1])
        quote
            function $(esc(ex.args[1].args[1]))($(esc.(ex.args[1].args[2:end])...))
                start_log($s)
                $(esc(ex.args[2]))
                end_log($s)
            end
        end
    else
        esc(ex)
    end
end

start_logとend_logも掲載させていただこう。

function start_log(func_name)
    println("$(func_name): write start log")
end

function end_log(func_name)
    println("$(func_name): write end log")
end

さて、このマクロは下記のように呼び出される。これは上手く動き、前後にログを表示してくれる。

@withlog_simple function add_simple(a, b)
    println(a + b)
end
#次のような出力が得られる。
add_simple: write start log
5
add_simple: write end log

なんということだ。関数定義と非常に似た形式で呼び出せるマクロが作れてしまっているではないか!何が起こっているのだろうか?これにはマクロの特別な呼び出し構文が関わっている。

Juliaのマクロの呼び出し形式

Juliaのマクロは、通常の関数呼び出しの形式(括弧で括ってカンマで分ける形式)の他に、括弧を省略した形式での呼び出しがある。例えば、addというマクロがあり、引数を2つとるとすると、次のような両方の呼び出し方が可能になる。

@add(1, 2)
@add 1 2

私はなぜマクロにだけこのような呼び出し形式が存在するのか不思議だった。何が嬉しいかよくわからなかった。しかし、あんちもん2氏のマクロはこの形式を非常に有効に活用している。

括弧を省略する形式には、次の特徴がある。

  • スペースで区切られた要素を機械的に引数に割り当てるのではなく、構文として成立するかを加味しながら、なるべく少ない数の引数に割り当てる

まずこの定義だが、引数の数は1つである。

macro withlog_simple(ex)
...
end

そして、呼び出しの形式を見てみる。

@withlog_simple function add_simple(a, b)
    println(a + b)
end

withlog_simpleに与えられたものは、単純にスペースで区切ると "function", "add_simple(a, b)", "println(a", "+", "b)", "end" である。しかし、これらは全て合わせると1つの関数定義と判断できるので、マクロの引数exに関数を丸ごと渡すことができるのだ。

マクロに引数で渡された関数はExpr型のデータになっている。ここまでくればもうこちらのものである。headやargsを指定して取得し(これはS式の先頭要素と2番目以降要素に相当)、構文を組み立てまくればいい。

Lispの場合は、マクロ呼び出しで渡す引数が、マクロ定義の仮引数に当たり前のように分配される。そのため、マクロ内でわざわざ分解して取得する必要がないというのが強みだが、それだけといえばそれだけである。呼び出し形式が自然であれば、マクロの中身が多少ごちゃごちゃしていようが、私はあまり気にならない。

なお、括弧なしで呼び出す構文を使わずに、こう呼んでも別にいい。

@withlog_simple(function add_simple(a, b)
    println(a + b)
end)

これは全く同様に動くし、関数定義っぽいという意味でも私は許容範囲である。もっとも、括弧無しバージョンの方がかっこいい。

結局のところ私の敗因は、Lispの場合は引数が自動で分配され、それがあまりに自然なものに見えたので、そこに目を奪われすぎたということにある。別にマクロ呼び出しに関数定義そのものを渡したって良かったのである。マクロ引数が抽象構文木に変換される以上、S式だろうがExpr型だろうが要素へのアクセスの容易さは同程度であり、そうなるとLisp側に格別の優位があるとは言えない。Lispの同図象性という点の主張も、見当はずれだった。

ここまで見てみると、Juliaのマクロ、本当にLispのマクロと同じくらい強力なんじゃないか?という気がしてくる。

そうなると、Juliaのマクロは本当に凄いことになる。健全さも加味すると、Lispのマクロより上なんではないだろうか。凄いぞJulia。前回の記事では君のことを過小評価してしまっていた。全く私が悪かった。大変申し訳ない。許しておくれ。君がこの世に生まれたことを、私はとても嬉しく思う。

・・・しかし、Lispよ。君は本当にそうなのかい?Juliaに負けちまうのかい?こんな、非の打ち所のない、小学校6年生の夏にやってきたハンサムでスポーツ万能な転校生みたいな奴に負けてもいいのかい?その上このハンサムボーイは勉強ができて性格まで良いんだ。どうなんだい?悔しくはないのかい?君の体は全てリストでできているんだろう?その究極の構造を発揮した、私などには思いもつかない素敵なマクロはできないのかい?

Lispの反撃

では、Lispのマクロにはもう一つも良いところはないのだろうか?Lispは見た目ばかり奇妙なくせに能力も劣っているトンチキ野郎なのだろうか?いや、そんなことはない。Lispでなければ表現できないマクロは、やはりあるのだ。もう一度だけ私の戯言に付き合ってほしい。もう一度振り返って考えてみよう。

Juliaのマクロはどんなものだったか。Juliaのマクロに式を渡すと、マクロはExpr型でそれを受ける。Expr型のデータ構造を読み取るのは簡単である。headやargsで構文要素にアクセスできる。このアクセス容易性が非常な強みだ。

Lispのマクロもどんなものだったか。LispのマクロにS式を渡すと、マクロはS型でそれを受ける。S式のデータ構造を読み取るのは簡単である。carやcdrで構文要素にアクセスできる。このアクセス容易性が非常な強みだ。*2

おやおや、同じ説明になってしまった。引数の読み取りは引き分けだ。

Juliaのマクロはどんな風にコードを組み立てるだろうか?最終的に返すのはExpr型だ。Expr型ってどうやって作るのだったか。コンストラクタがある。Expr(:call, :+, :a, :b)みたいなやつだ。それからquote構文がある。:()とquote〜endである。それからMeta.parse関数というものもある。文字列で式を与えるとExpr型を返すのだ。大体こんなところだ。

Lispのマクロはどんな風にコードを組み立てるだろうか?最終的に返すのはリストだ。リストってどうやって作るのだったか。リストの作り方はたくさんある。 だから、Juliaのマクロに勝つとすればそこなのだ。

Lispはリスト処理がとても得意だ。何と言ってもLispの名前の由来はList Processingなのだ。なので、とてもここに列挙しきることはできない。Lispはリストを切ったり貼ったり変換したりがとても得意だ。あるリストから別のリストへ変形する、極めて複雑な処理を書くことができる。

そのレベルになると、私のようなトンチキ野郎にはとても手に負えない。On Lispの著者であるPaul Graham大先生の力を借りよう。

次のマクロはOn Lispからの引用だ。dbindというマクロで、構造化代入という名前がつけられている。複数の変数に一度に代入する処理だ。

On Lisp --- 構造化代入

このdbindマクロはこのように使う。

(dbind (a b c) #(1 2 3)
  (list a b c)) ;(1 2 3)

(dbind (a (b c) d) '(1 #(2 3) 4)
  (list a b c d)) ;(1 2 3 4)

マクロの定義はこれだ。

(defmacro dbind (pat seq &body body)
  (let ((gseq (gensym)))
    `(let ((,gseq ,seq))
       ,(dbind-ex (destruc pat gseq #'atom) body))))

(defun destruc (pat seq &optional (atom? #'atom) (n 0))
  (if (null pat)
      nil
      (let ((rest (cond ((funcall atom? pat) pat)
                        ((eq (car pat) '&rest) (cadr pat))
                        ((eq (car pat) '&body) (cadr pat))
                        (t nil))))
        (if rest
            `((,rest (subseq ,seq ,n)))
            (let ((p (car pat))
                  (rec (destruc (cdr pat) seq atom? (1+ n))))
              (if (funcall atom? p)
                  (cons `(,p (elt ,seq ,n))
                        rec)
                  (let ((var (gensym)))
                    (cons (cons `(,var (elt ,seq ,n))
                                (destruc p var atom?))
                          rec))))))))

(defun dbind-ex (binds body)
  (if (null binds)
      `(progn ,@body)
      `(let ,(mapcar #'(lambda (b)
                         (if (consp (car b))
                             (car b)
                             b))
                     binds)
         ,(dbind-ex (mapcan #'(lambda (b)
                                (if (consp (car b))
                                    (cdr b)))
                            binds)
                    body))))

解説はしないが、極めて複雑な処理をしていることがわかるだろう。リストを切ったり貼ったり変換したり大変な騒ぎだ。JuliaのExprでこのようなことができるのだろうか?さて、私にはわからない。想像する範疇では、難しそうな気がする。Exprのコンストラクタをすごく頑張ったらできるのかもしれないが、あまりに複雑で手に負えなくなるのではないだろうか。しかしそれは憶測に過ぎない。*3

まとめ

前回の記事で結論を出した時よりも、Juliaは大きな力を持っていることがわかった。少なくとも私が判定できるレベルの複雑さの処理では、JuliaのマクロがLispのマクロよりも劣っているという証拠はない。私の判定できないレベルで、違いがあるかもしれないということがぼんやりとわかる程度だ。

また、もしも、JuliaがExprを操作する能力が、Lispのリストを操作する能力と同等であれば、これで反論の余地はない。Juliaは完全にLispに到達したと認めるしかないだろう。この点もよくわからない。さらなる情報を求むところだ。

そういったわけで、現時点での私の実力では判定不能だ。ともかく、Juliaは思っていたよりも凄いやつだった。凄いぞJulia。頑張れJulia。私に言えるのはそれだけだ。

*1:これらは別に重要ではない。ただ私はコードを写経したりコピペしたりしたときに動かないと途端にやる気をなくすので、書いた方がいいかなと思っている。

*2:え?carやcdrって何だって?リストの先頭要素の取得がcarで2つ目以降の取得がcdrだ。名前がわかりづらいって?ガタガタいうんじゃない。すぐ慣れる。どうしても嫌ならcarやcdrを呼び出すマクロを作ってそれを好きなだけ呼びなさい。

*3:このマクロだって手に負えないと思うかもしれない。確かに、一見するととんでもない怪物に思えるかもしれないが、自分でdbindを書こうとしてみたら、意外とそれぞれの処理が何をやっているかわかってくる。経験上、複雑なマクロは読むだけでは理解できない。macroexpandなどを使っても限界がある。自分で書いてみることだ。

JuliaとLispのマクロの比較

Juliaというプログラミング言語が最近ホットだという。どうやら数値計算系の界隈を特に席巻している言語だそうである。

数値計算プログラミングというのは数学や物理学などの分野で登場する数式をコンピュータに解かせるという分野のことだ。もちろんプログラムを書いて解かせるわけだが、同じソフトウェアであっても、今時のキラキラWeb系プログラムやスマホアプリなどの分野とは随分違う。

入力は数値、出力も数値、デバッグ中も数値、寝てる間も数値、数値、数値、数値。数式を理解して、プログラムに変換できるのは大前提。その上で、「その式変形、数学的には等価だけど、桁落ちの誤差の評価ってどうなってるの?」みたいな会話が飛び交う、それはそれは硬派で地味な世界なのだ。なお桁落ち誤差を改善したら、次は情報落ち誤差で責め立てられる。まあ世間一般からすると、懲役でも勘弁、みたいな仕事である。

そんな数値計算は、硬派で地味だが重要な分野である。NASAは月へロケットを飛ばすために数値計算を駆使した。台風の進路予測などは流体力学の方程式を数値計算で解くことで求める。みんな大好き人工知能ディープラーニングも裏では数値計算をゴリゴリに行っている。その他、枚挙に暇がないくらいに実例がある。気になる方はWikipediaを参照すること。

地味とは言え、シミュレーション結果を可視化すると、意外と見栄えも良かったりする。数式の解の動きを図示したシミュレーション結果を見ると、複雑な中にも調和が見られたりして、ため息が出るほど美しいこともある。私は数値計算が好きである。(※得意ではない)

さて、数値計算界で使われる主要な言語はFORTRANである。

FORTRANとは1954年に生まれた、現役の言語の中では世界最古のプログラミング言語の1つである。古い言語の代表格のC言語が1972年生まれなので、いかに古参かということがわかると思う。最長老様である。

数値計算界は今でもFORTRANを使っている。過去に多くのプログラムがFORTRANで書かれてきた。今後もFORTRANのプログラムは多く書かれるだろう。なぜそんなにFORTRANにこだわるのか?理由はいろいろある。だが、何と言っても高速なのである。数値計算業界は、とにかくコンピュータに大量の計算をさせる。宿命的にそうなのだ。今のプログラムが所望の結果を出すのに2週間かかかるとする。コンピュータの性能向上でが計算速度が2倍になったとしても、1週間でやめたりはしない。やっぱり2週間かける。それで、より良い結果を追い求めるのである。

普通のプログラムでも速度はそれなりに大事だが、ユーザーが不快に感じないくらいであればそれでいい。一方、数値計算では計算速度そのものが、シミュレーションの結果に大きく影響する。

さて、数値計算業界が、計算機をキリキリに締め上げているのを余所に、世間のプログラミング言語はもっとゆるふわな方向に進化してきた。PythonRubyなどの動的言語の台頭である。

これらの言語は書いていて実に快適だ。細かいことをごちゃごちゃ指定しなくて良い。人間の書いたソースコードは、どこかのタイミングで機械語に変換される必要がある。FORTRANなどの静的なプログラミング言語は、プログラムを書いて、全体を機械語に変換して、それから実行する。Rubyなどの動的なプログラミング言語は、プログラムを書いて、実行しながら機械語に変換していく。実行しながら機械語に変換する性質により、プログラムを書く時点で決めておかなければならないことが少なくなる。代償として、プログラムの実行速度はかなり遅くなってしまう。

遅いと言っても問題にならないことも多い。計算機のマシンパワーは近年急速に増大してきた。そもそも、重たい処理を必要としないプログラムも多いのだ。そういったケースでは、RubyPythonはとても良い選択肢だ。

もちろん数値計算業界ではそうはいかない。重たい処理を解くことが使命の業界なのだ。そんなわけで、PythonRubyなどは、数値計算業界では計算の軽い部分に使うことはあっても、メインとなる処理はFORTRANで書かれる。仕方のないことだ。

FORTRANというのは幸いにして難しい言語ではない。少なくともC言語よりはずっと簡単だ。だから、数値計算業界に入ってきた新人にFORTRANを学ぶことはそう高いハードルではない。多少古めかしさはあるが別に読みづらいというほどではない。配列のインデックスが1から始まることには驚くかもしれないが、計算させたい数式の添字も1から始まることに気づき、むしろその方がやりやすいことに気づくだろう。なんだかんだで、FORTRAN悪くないじゃん、いやむしろライブラリやサンプルコードも充実しているし、最適解ではないだろうか。そんなこんなで30年、40年やってきたのである。

そんな数値計算業界に、突如として彗星の如く現れたのがプログラミング言語「Julia」であった。Juliaの宣伝文句はすごい。引用してみよう。

僕らが欲しい言語はこんな感じだ。まず、ゆるいライセンスのオープンソースで、Cの速度とRubyの動的さが欲しい。Lispのような真のマクロが使える同図象性のある言語で、Matlabのように分かりやすい数学の記述をしたい。Pythonのように汎用的に使いたいし、Rの統計処理、Perlの文字列処理、Matlab線形代数計算も要る。シェルのように簡単にいくつかのパーツをつなぎ合わせたい。チョー簡単に習えて、超上級ハッカーも満足する言語。インタラクティブに使えて、かつコンパイルできる言語が欲しい。

(そういえば、C言語の実行速度が必要だってのは言ったっけ?)

こんなにもワガママを言った上だけど、Hadoopみたいな大規模分散コンピューティングもやりたい。もちろん、JavaXMLで何キロバイトも常套句を書きたくないし、数千台のマシンに分散した何ギガバイトものログファイルを読んでデバッグするなんて論外だ。幾層にも重なった複雑さを押しつけられるようなことなく、純粋なパワーが欲しい。単純なスカラーのループを書いたら、一台のCPUのレジスターだけをブン回す機械語のコードが生成されて欲しい。A*Bと書くだけで千の計算をそれぞれ千のマシンに分散して実行して、巨大な行列の積をポンと計算してもらいたい。

型だって必要ないなら指定したくない。もしポリモーフィックな関数が必要な時には、ジェネリックプログラミングを使ってアルゴリズムを一度だけ書いて、あとは全ての型に使いたい。引数の型とかから自動的にメソッドを選択してくれる多重ディスパッチがあって、共通の機能がまったく違った型にも提供できるようにして欲しい。これだけのパワーがありながらも、言語としてシンプルでクリーンなものがいい。

これって、多くを望みすぎてるとは思わないよね?

なぜ僕らはJuliaを作ったか

これを喋っているのが金融商品の勧誘員だったら詐欺としか思えない説明だ。そんな都合のいい話があるわけがない。怪しげな新興国の年利30%の企業社債(為替影響込みの元本保証あり!)みたいな話だ。 しかし、どうやらこれは金融商品ではなくプログラミング言語で、緩いライセンスのオープンソースというところは少なくとも真実で、つまり騙されるにしても失うのは自分がJuliaを試してみた時間だけで、どうやら子供の将来ための学資保険は解約せずに済みそうなのだ。

しかし私はビビッときたのだ。 「Lispのような真のマクロが使える同図象性のある言語」 ここに引っかかってしまったのだ。「Lispのような真のマクロ」は、Lispでしか実現できない。私はそう信じてずっと生きてきたのだ。いや、ずっと、というのは言いすぎだ。しかし、ここ5年くらいはそうしてきたのだ。

Lispとは

Lispについて紹介しよう。LispというのはFORTRANと同じくらい古い言語だ。生まれは1958年と言われている。

Lispはプログラミング界の異端児だ。何が異端かというと、いろいろ異端なのだが、何よりその見た目だ。普通のプログラミング言語と比較すると、地球人と火星人くらい違う。(火星人には会ったことないけど!)

Juliaと比較してみよう。Juliaについてはあまり細かい説明はしない。Juliaは読みやすいので、Julia自体知らなくても、何かのプログラミング言語を知っていればわかると思う。

Lispの大きな特徴は、前置記法である。オペレータが必ず先頭に来るのだ。xとyを足すときには、(x + y) ではなく(+ x y)となる。 もう1つの特徴は、区切りの記号に丸括弧とスペースしかないことである。

コードを見てもらった方が早いだろう。 LispのサンプルコードはCommon Lispで書いている。Lispは歴史が長いのでLisp族と呼ばれるいろいろな言語が存在するが、Common Lispというのは代表的な一派だ。

これは受け取った引数の値を合計する関数である。なお、defunというのはCommon Lispの関数宣言である。

Julia

function add(x, y)
  x + y
end

Common Lisp

(defun add (x y)
  (+ x y))

このくらいであれば、まだあまり違いはない。

次は、階乗の計算をするプログラムである。

Julia

function factorial(n)
  if (n == 0)
    1
  else
    n * factorial(n - 1)
  end
end

Common Lisp

(defun factorial (n)
  (if (= n 1)
      1
    (* n (factorial (- n 1)))))

かなり違いが出てきた。Juliaはそう変わっていないが、Common Lispにはかなり括弧が増えてきた。

この括弧こそが、Lispが奇妙な言語に見える部分だ。想像されるように、もっと大きな関数だともっと括弧だらけになっていく。 そしてこれこそがLispにある種のパワーを与えているのだ。この点についてはのちに触れていくことにする。

マクロとは

マクロという機能について説明しよう。一部の言語はマクロという機能を備えている。ざっくりと言うと、マクロ機能というのは「ソースコードを生成・改編するソースコード」を取り扱う機能のことである。

もう少し正確に言うと、書かれたソースコード に悪戯をして、プログラムの動作を変更する機能全般を指して、一般的にメタプログラミングと呼ぶ。そのうち特に、コンパイル時に行われる処理で発動して、ソースコードを生成・改編する機能が、「マクロ」と呼ばれることが多い。

マクロ機能を備えていない言語も多い。

マクロ機能を備えていると言っている言語でも、提供されている機能は異なることが多い。C言語のマクロ機能とLispのマクロ機能には大きな差がある。 Julia言語はマクロ機能を備えていると宣言している。Julia言語がどのあたりの位置づけになるか、ということが本稿の主題である。

また、事実上マクロ機能なのに、マクロという名前がついていないこともある。C++のテンプレート機能はマクロ機能と呼んで差し支えないと思うが、おそらくC言語から受け継いだのマクロ機能と区別するためにこの名前になっている。C#にはT4テンプレートという機能があり、これもマクロ機能の一種である。ただこれはC#の機能というよりは統合開発環境であるVisual Studioの機能なので、言語機能に含めていいかは微妙かもしれない。

ちょっと散らかり気味になってしまったがまとめると、

このあたりはそんなにカッチリ定義の決まっているものでもなさそうが、私は大体そのような感覚で捉えている。
こちらも実例を紹介するのが早いだろう。C言語Lisp、そしてJuliaのマクロ機能を紹介する。

C言語のマクロ

C言語のマクロはあまり強力ではない。しかしわかりやすいので、まずマクロ入門という位置づけで紹介する。このあと登場する強力なLispのマクロの引き立て役でもある。福山雅治の隣に立った私のことだ。

また、前半は、C言語のマクロ知っている人には常識的な内容なので飛ばしてもらっても良いが、コード生成の話につながる#演算子のあたりからは読んでほしい。

C言語のマクロは #define という記号で定義される。下記のコードで言うと、「"PI"って文字は、"3.14"に置き換えてコンパイルしておくれよー」という意味である。

#define PI 3.14
 
//半径を受け取って円の面積を計算する
float calc_circle_area(float radius) {
    float area = PI * radius * radius; 
    return area;
}

通常ソースコード は、そのままコンパイラに渡されて機械語に翻訳される。ところが、マクロが定義されていると、そこに一段階処理が挟まるのだ。これがプリプロセスと呼ばれる処理である。プリプロセスが終わった後のプログラムは、下記のように姿を変えている。

//半径を受け取って円の面積を計算する
float calc_circle_area(float radius) {
    float area = 3.14 * radius * radius; 
    return area;
}

ソースコードの文字列がそのまま置換されているイメージである。

マクロは、このように定数値の置き換えで使われることの他に、関数っぽく使われることも多い。

#define PI 3.14
#define SQUARE(x) x * x //注意! バグあり!!
 
//半径を受け取って円の面積を計算する
float calc_circle_area(float radius) {
    float area = PI * SQUARE(radius); 
    return area;
}

プリプロセスが終わった後のプログラムは、下記のように姿を変えている。

float calc_circle_area(float radius) {
    float area = 3.14 * radius * radius; 
    return area;
}

めでたしめでたし、とはいかない。このSQUAREマクロにはバグがあるのだ。

次のようなケースを考えよう。

#define SQUARE(x) x * x //注意! バグあり!!

SQUARE(1 + 2); //9を期待

プリプロセスが終わった後のプログラムは、下記のように姿を変えている。

1 + 2 * 1 + 2; //なんと5

これはC言語のマクロがあくまで文字列置換であるからだ。

これを防ぐには、次のように括弧をつける必要がある。

#define SQUARE(x) ((x) * (x))

SQUARE(1 + 2); //9を期待

こうすると上手くいく。

((1 + 2) * (1 + 2)); //めでたく9

こちらがひとかたまりだと思っているものがバラけてしまわないように、括弧で優先順位を明確にする必要があるのだ。ちょっと癖の強い機能ということが分かると思う。

さて、C言語のマクロのメリットとは何だろうか?これには3つある。

  • コンパイルより前に文字列置換されるために、インライン化を強制できる。

  • 総称関数の定義ができる。

  • コード生成ができる。

1つ目のインライン化は、関数呼び出しのオーバーヘッドを避けるためのものである。関数呼び出しは僅かながらコストのかかる処理である。これを避けるために、「関数のインライン化」という手法が取られることがある。ソースコード 上では関数を呼び出しとして記述されているが、コンパイル時に、呼び出し先関数の中身を呼び出し元関数に埋め込むイメージである。こうすることで、実行時に関数を呼び出す必要がなくなる。

普通のパソコンでは余程のことがない限り気にしなくても良いと思うが、C言語は電化製品などに組み込まれたソフトによく使われる。こういったときには非常に大事になってくる。このような環境は、通常、計算機資源が非常に限定されているからである。CPUやメモリもパソコンのものと違うため、そもそもmain処理から呼び出せる関数の階層(深さ)自体が制限されていることもある。こういった制約のある環境では、マクロは非常に心強い機能となってくれる。

2つ目の総称関数の定義というのは、言葉は小難しいが、内容は大したことはない。SQUAREマクロでもやっている。SQUAREマクロはこう定義した。

#define SQUARE(x) ((x) * (x))

そして、使い方を2つ示した。

//例1
float calc_circle_area(float radius) {
    float area = PI * SQUARE(radius); 
    return area;
}
//例2
SQUARE(1 + 2); 

例1では、float型を引数にとり、例2では、int型を引数にとっている。C言語の関数ではこれはできない。内部の式が一緒であっても、float引数用の関数と、int引数用の関数を別個に定義しなければならない。これは有効なトリックなので、組み込み環境で使われるケース以外でもよく使われる。ただ、先進的な言語であれば、この問題は別の形で解決されている。総称関数、ジェネリック関数、などという名前の機能が提供されていると思う。

3つ目のコード生成については、新たに例を出す必要がある。#演算子というものである。

この演算子の働きは、マクロに渡された引数にダブルクオーテーションをつけて文字列化するのである。引数の中身にではない。引数として渡されたもの自身である。コードを見てみよう。

下記のようなSYMBOLマクロを定義する。SYMBOLマクロは、引数に#演算子を適用しているだけである。

#define SYMBOL(x) #x

次のコードで出力される結果は、変数名の"num"である。変数numの中身の100は表示されない。

#include <stdio.h>
#define SYMBOL(x) #x
int main(void){
    int num = 100;
    printf("%s\n", SYMBOL(num)); //numと出力される。
    return 0;
}

このように処理されるからである。

#include <stdio.h>
int main(void){
    int num = 100;
    printf("%s\n", "num"); 
    return 0;
}

これだけでは何に使うかわからないかもしれないが、少し面白い使い方ができる。

下記のようなマクロを定義する。このマクロはデバッグプリント時に使う。値を調べたい変数を引数にセットすると、変数名とその値を出力してくれるのだ。

#define DEBUG_PRINT_INT(x) printf("%s:%d\n", #x, x);
#include <stdio.h>
#define DEBUG_PRINT_INT(x) printf("%s:%d\n", #x, x);
int main(void){
    int num = 100;
    DEBUG_PRINT_INT(num); //"num:100"と表示される。
    return 0;
}

マクロが下記のように展開されるからである。通常はプログラマが手でprintf(〜, "num", num)と書くのであるが、これをマクロに代行させているのである。これが、マクロによるコード生成の意味である。

#include <stdio.h>
int main(void){
    int num = 100;
    printf("%s:%d\n", "num", num); //ここをマクロが生成してくれている。
    return 0;
}

このような芸当はC言語ではマクロにしかできない。通常、関数のローカル変数のシンボル名の情報などは、コンパイル時に消え失せてしまうためだ。マクロが動くのはコンパイルより前のプリプロセス処理なので、こういうことができる。

さらに##演算子というものがある。例は省略するが、##演算子は、マクロ定義の中で、文字列連結をしたいときに使用する。これを活用することで、さらに複雑なコード生成を行うことができる。

C言語のマクロを使ったより高度なコード生成例が下記サイトで紹介されているのでリンクを貼っておく。

www.nurs.or.jp

Lispのマクロ(前文)

ようやくここまで到達した。LispのマクロとJuliaのマクロを比較するのがこの記事の主題なのだ。

さて、Lispのマクロは極めて強力である。先ほど紹介したC言語のマクロはそれほど多くのことはできなかった。マクロ定義は文字列置換であり、追加で#演算子と##演算子が使えるだけである。これだけでもできることは結構あるし、マクロでなければできないことも見てきた。しかし、例えば、マクロ定義の中で、条件Aが満たされていたらこのコードを生成、そうでなければこのコードを生成、というようなことはできない。

Lispのマクロはそうではない。Lispのマクロ定義中では、Lispの文法で定義された構文は全て使うことができる。Lispのマクロの定義は、Lispの関数の作成とほとんど同じ感覚で行える。こちらも実物のコードを見てもらうのが良いだろう。

と思ったが、その前にまずはLispコードについて説明しなければならない。マクロはコードを作る。コードについて知らなければ話にならない。

Lispの構文

上の方でも触れたが、Lispのコードは非常に奇妙な形をしている。JuliaとLispの階乗を計算するコードを再掲する。

Julia

function factorial(n)
  if (n == 0)
    1
  else
    n * factorial(n - 1)
  end
end

Common Lisp

(defun factorial (n)
  (if (= n 1)
      1
    (* n (factorial (- n 1)))))

一見奇妙に見えるこの構文は実は非常なメリットがあるのだ。順序立てて解説していこう。

defunというのは、関数定義の宣言をしている。defunは次のような構文ルールになっている。

(defun 関数名 引数 関数本体)

関数宣言は、先頭がdefunのリストである。「リスト」というのはLispの中心的なデータ構造で、()で囲まれ、スペースで区切られた、データの並びのことである。データとは何かというと・・・何でも良い。リストは非常に柔軟なデータ構造だ。配列とは似ているが違う。配列は通常、同じ型の要素しか入らない。intの配列、decimalの配列のような感じだ。Lispのリストは内部にどのような要素でも含むことができる。

今回の例で言うと、
要素1: defun: シンボル
要素2: factorial: シンボル
要素3: (n): リスト
要素4: (if (= n 1) 1 (* n (factorial (- n 1)))): リスト
という4つの要素を持つリストである。

シンボルというのはこれ以上分解できない記号のことである。何かの数値や文字列や他のリストと紐づけることができる。紐付けなくても良い。Lispのシンボルは、大文字と小文字を区別しない。

重要なのは、defun構文を読み解くときに、スペースで切り出すだけで要素に分解できたと言うことである。

要素1と要素2はシンボルなのでこれ以上分解できない。要素3を見てみよう。これは要素数が1のリストである。要素4はif式と言う構文である。

(if 条件式 条件が真の時に実行される式 条件が偽の時に実行される式)

if式は、先頭がifのリストである。 今回の例で言うと、
要素1: if: シンボル
要素2: (= n 1): リスト
要素3: 1: 数値
要素4: (* n (factorial (- n 1))): リスト

要素1と3はシンボルと数値だ。要素2は先頭が=記号のリストだ。これは、与えられた2つの要素が等しいかどうかを判定する関数である。要素4は先頭が*記号のリストだ。これは、与えられた要素の積を計算する関数である。

ここまでの流れは他の構文も同様である。Lispの構文はリストで定義される。リストの先頭がオペレータである。例外はない。*1このように表現される構文をS式と呼ぶ。

これはLispのプログラムを処理するLisp言語のコンパイラインタプリタを作成する時に、非常に役に立つ。何と言っても、単純なのだ。リストを見つけたら、スペースで区切って要素に分解する。先頭の要素はオペレータだ。該当する処理を行え。残りは引数だ。処理に渡してやれ。以上である。

もちろん代償はある。我々は幼少期から数学教育の訓練を受けてきた。そのため、数式は 1 + 2 * 3 のように、数値とオペレータを混ぜこぜで書きたくなるし、 1 + 2 * 3 のような式を見ると、 2 * 3 を先に計算してくてしょうがなくなるのだ。多くの言語はその気持ちを尊重してきた。 1 + 2 * 3 と書くことを許し、2 * 3 を先に計算するように尽力してきた。しかしLispは違う。「2と3を掛けてから1を足したいだと? その通りに書けば良いじゃないか。 (+ 1 (* 2 3)) だ。わかったな。」

もしも、Lispの構文がこのようになっている理由がコンパイラインタプリタの作者のためだけであれば、この制約は不当である。Lispもあっという間に1 + 2 * 3と書けるようになっていただろう。しかし、Lispはそうならなかった。言語のユーザーも今の形であることを望んだ。その理由が「マクロ」にあるのだ。

Lispのマクロ(本文)

それではLispのマクロを見ていこう。

まずは、C言語と同じく与えられた引数を2乗する処理を記述しよう。Common Lispの関数版とマクロ版を記述する。なお、関数版とマクロ版で命名が違うのは、単に分かりやすさのためで、別にマクロをm-から始める必要があるわけではない。defmacroというのが、Common Lispのマクロ定義の宣言だ。

; 関数定義
(defun square (x)
  (* x x))

;マクロ定義
(defmacro m-square (x)
  `(* ,x ,x))

さて、関数版は特にコメント不要だろう。問題はマクロ版だ。急に意味不明な記号が登場してきた。バッククォート「`」と、カンマ「,」だ。記号の意味を解説する前に、そもそもマクロで何が行われているのかを話した方がいいだろう。C言語のマクロは文字列置換を行うのだった。では、Lispのマクロはというと、コードを組み立てているのである。関数は値を返す。マクロはコードを返す。

具体的に見ていこう。まず、Lispのマクロの呼び出し方は、関数と同じである。

; 関数呼び出し
(square (+ 1 2))

;マクロ呼び出し
(m-square (+ 1 2))

関数呼び出しの際には、次のような流れで処理が動く。

  1. (+ 1 2)が評価され、3に置き換えられる。
  2. squareの内部の(* x x) が (* 3 3)に置き換えられ、9と評価される。
  3. 9が返される。

コードで段階を追って表示すると、下記のようになる。

; 関数定義
(defun square (x)
  (* x x))

; 関数呼び出し
(square (+ 1 2))
(square 3)
(* 3 3)
9

次にマクロ呼び出し処理を説明する。マクロの実行は2段階に分かれていると意識すると良い。マクロ展開時と実行時である。

マクロ展開というのは、コードを書き上げたあと、コンパイルを行うときに行われる。通常、書き上げられたコードは、マクロ呼び出しと関数呼び出しが混在している。マクロ展開の際には、マクロ呼び出し部分だけを拾って行き、定義したマクロにしたがって評価していく。そして、評価した結果(普通はコードとして解釈できるリスト)を、マクロ呼び出しが行われていた箇所に、ペタペタと貼って置き換えていくのである。マクロ展開が終わると、マクロ呼び出しはコードからはもはや消え失せており、普通の式や関数呼び出しだけが残るのである。*2

マクロ展開時

  1. (+ 1 2)はそのままの形でマクロ内部に渡される。
  2. マクロ内部では、xに(+ 1 2)が割り当てられる。この(+ 1 2)はリストである。
  3. m_square内部の,xが(+ 1 2)に置き換えられ、`(* (+ 1 2) (+ 1 2))となる。
  4. (* (+ 1 2) (+ 1 2))が返される。

実行時

  1. (* (+ 1 2) (+ 1 2))で(+ 1 2)が3と評価される。
  2. (* 3 3)が9と評価される。
  3. 9が返される。

コードで段階を追って表示すると、下記のようになる。細かい部分は、後ほど説明する。

;マクロ定義
(defmacro m-square (x)
  `(* ,x ,x))

;マクロ呼び出し
(m-square (+ 1 2))

;マクロ展開後
(* (+ 1 2) (+ 1 2)) ;ここに注目

;実行時
(* (+ 1 2) (+ 1 2))
(* 3 3)
9

違いがわかっていただけたと思う。関数呼び出しが完了すると、9という値が返った。マクロ呼び出しが行われると、マクロ展開により(* (+ 1 2) (+ 1 2))というコードが返り、最終的な実行時の評価で9になる。

では、マクロ定義を解説する。

;マクロ定義
(defmacro m-square (x)
  `(* ,x ,x))

まず最初の「`」である。これはバッククォートや逆クォートと呼ばれる記号である。これは何だろうか?

マクロはコードを返すと言った。Lispのコードはリストである。なので、結局、マクロはリストを作って返すのだ。Lispでリストを作る方法はいくつかある。

代表的なのがlist関数である。この関数は、引数をそのままリストにする。

(list 1 2 3) ;(1 2 3)となる。

次が、quoteである。これは、働きは一見list関数と似ている。

(quote (1 2 3)) ;(1 2 3)となる。

しかし、実際には違う。その例を示そう。

(list a 2 3) ;aという変数が存在しないというエラーになる
(quote (a 2 3)) ;(a 2 3)となる。

quoteは実際にはリストを作っているわけではない。quoteの働きとは何か?それは、lispコードの評価器に、コードの評価をやめてくれるよう伝えることだ。

(list a 2 3)を渡された評価器は、こう考える。

「aと2と3をまとめたリストを作れば良いわけだな。aってなんだ?シンボルか。ん?定義されてないぞ!エラーだ!」

(quote (a 2 3))を渡された評価器は、こう考える。

「(a 2 3)ってのが何者か知らないが、調べるなって言われてるんだから調べないぞ。答えは(a 2 3)だ!」

これがquoteの正体である。なので、引数はリストでなくても良い。例えば引数にシンボルをとって、(quote a)とすると、シンボルaが返る。そして、このquote処理の省略形がクォート記号「'」である。

(quote (a 2 3));(a 2 3)
'(a 2 3);(a 2 3)

なお、引数にリストを取ったときのquoteは、list関数で引数全てにquoteをつけたものと同じになる。

(list 'a '2 '3) ;aという変数が存在しないというエラーになる
(quote (a 2 3)) ;(a 2 3)となる。

そして、逆クォート記号「`」である。バッククォートとも言う。これは、クォート記号とほとんど同じだ。

'(a 2 3);(a 2 3)
`(a 2 3);(a 2 3)

違いは、内部にカンマ記号「,」を含んでいるときに現れる。カンマを含めると何が起こるのだろうか?クォート記号の働きとは、lispコードの評価器に、コードの評価をやめてくれるよう伝えることだった。しかし、バッククォートの場合、カンマ記号があると、その部分だけは評価するのだ。

次のコードを見てみよう。letというものが出てきたが、これはローカル変数の宣言だ。aという変数は1だと宣言している。

一番上はクォート記号で、中身を何も評価しないので、中身そのままの(a 2 3)を返す。カンマを含まないバッククォートも同様だ。しかし、最後のカンマを含むバッククォートは、aの値を評価して、結果に反映する。

(let ((a 1))
  '(a 2 3));(a 2 3)

(let ((a 1))
  `(a 2 3));(a 2 3)

(let ((a 1))
  `(,a 2 3));(1 2 3)

カンマはバッククォートをlist関数で展開したときに、クォート処理を打ち消すと考えてもいい。`(a 2 3)は(list 'a '2 3')と等価であり、`(,a 2 3)は(list a '2 '3)と同じである。

`(,a 2 3)は、(list a '2 '3)と同じ。

(let ((a 1))
  `(,a 2 3));(1 2 3)

(let ((a 1))
  (list a '2 '3));(1 2 3)

一応これで、マクロm-squareの動作は理解できることになる。

(defmacro m-square (x)
  `(* ,x ,x))

しかし、どうせなので、なぜマクロを普通の関数みたいに書いてはいけないかを考えてみることにしよう。

まず、普通の関数と同じに書いてみる。

(defmacro m-square (x)
  (* x x))

これがマクロ展開されるとどうなるか?マクロ展開処理は、関数呼び出しと非常に似たプロセスで行われる。違いは引数の評価である。関数呼び出しは、処理の開始前に引数を評価するが、マクロ呼び出しは引数を評価せずに、処理を開始するのである。引数の評価についてはプログラマに完全に任されており、一度も評価なくてもいいし、何度も評価してもいい。

そのため、下記のようにマクロを呼び出すと、

;マクロ呼び出し
(m-square (+ 1 2))

下記のように動き、エラーとなる。

;マクロ展開の途中経過を抜き出したもの。引数を評価せずに中身の処理まで持ってきている。
(* '(+ 1 2) '(+ 1 2));リストとリストの掛け算は定義されていないのでエラーとなる。

引数の評価がされないのが問題であれば、引数の評価を行えば良い。ということで下記のように書いてみると、これは文法エラーとなる。

;引数の評価はカンマを書けばいいんだよね?と書いてみた。
(defmacro m-square (x)
  (* ,x ,x));しかし文法エラー

カンマはバッククォートの中でのみ有効だからだ。しかし、実は引数の評価をさせる別の方法がある。evalという処理である。これは、lispコードを評価するための処理だ。

;evalの例。リスト(+ 1 2)を、コードとして評価する。
(eval '(+ 1 2));3となる

これを使うと、どうなるだろうか?

;evalで評価してローカル変数yに値を代入してから、yの二乗を計算する。
(defmacro m-square (x)
  (let ((y (eval x)))
    (* y y)))

同じく次のように呼び出す。

;マクロ呼び出し
(m-square (+ 1 2))

これは、下記のように動き、結果も正しく9となる。

;マクロ展開の途中を抜き出したもの。
(let ((y 3)); 
  (* y y); これはエラーとならず、結果も正しく9となる。

しかし、この書き方にも問題がある。次のように書くとやはりエラーとなるのだ。

;マクロ呼び出し
(let ((a 1))
  (m-square (+ a 2)))

上記のようにコードを書くと、なぜエラーになるのだろうか?それはここでのマクロ定義がクォートされたリストを返す処理ではなく、値を返す処理だからである。値を返すためにはマクロ展開時に評価まで行わなければならない。すなわち、aに1が設定される前に、(m-square (+ a 2))が評価されるためである。そのため、マクロ内部のevalがうまく動作しないのである。

;マクロ展開の途中を抜き出したもの。
(let ((y (eval '(+ a 2)));(+ a 2)を評価できずにエラーとなる。
  (* y y)

マクロはコードを返す、コードはリストだ、と上の方で書いたのだが、実際にはマクロはリスト以外を返してもいい。実用的には大体の場合にコードを返すように作るというだけの話で、値を返してもいい。同様に、マクロ展開時にも、処理系が気を利かせて「マクロなんだからコードを返すんだよね〜?」とリストっぽいところまで到達したら評価を止めたりはしない。マクロ展開時には、マクロ定義に従って機械的に評価を進めていくのである。

そのため、マクロ処理はたいてい次のように書く。

  • マクロ展開では、式を最後まで評価することはせず、コード(=リスト)を作成されるようにしておく。
  • リスト内で引数を評価できるように、バッククォートで作成する。
(defmacro m-square (x)
  `(* ,x ,x))

この形であれば、次のように呼び出すと、

;マクロ呼び出し
(let ((a 1))
  (m-square (+ a 2)))

次のように展開され、実行時に動く。万事うまくいく。

;マクロ呼び出し
(let ((a 1))
  (* (+ a 2) (+ a 2)))

ところで、先ほど問題があると書いた下記の形式のマクロも、実はメリットが無いわけではない。

;evalで評価してローカル変数yに値を代入してから、yの二乗を計算する。
(defmacro m-square (x)
  (let ((y (eval x)))
    (* y y)))

マクロ展開はコンパイル前に実行される。その時点で上記の(* y y)まで実行が完了するのである。そのため、マクロに与える引数に変数が含まれていなければ、コードをコンパイルした時点で、既に必要な計算が全て完了しているのである。

;コード記述時
(m-square (+ 1 2))
;マクロ展開後
9
;実行時
9;マクロ展開で9となっているので、実行時には計算を行う必要がない。

このくらいの例であれば実行時に計算しても無視できるだろうが、非常に複雑な計算であれば、効果があるかもしれない。しかし、相当気をつけて使う必要はある。繰り返しになるが、マクロは値を返すこともある。マクロと関数の違いは、評価のタイミングと引数の制御である。それ以外に違いはない。

さて、マクロのm-squareをもとに、マクロの性質を見てきた。ここまでのコードでは、マクロのありがたみはまだわからないだろう。この例は動くマクロを説明するためのサンプルであり、実際には関数として作るべきものだからだ。もう少し我慢していただきたい。次のセクションでは、同じような例を使って、Juliaのマクロを見ていく。その後、もう少し実用的なマクロを紹介する。それを通じて、両者の比較を行っていく。

Juliaのマクロ

Lispのマクロはかなり長々とした説明になった。Juliaのマクロはよく似ているので、違いだけ説明することになる。例によって、引数で与えられた数値を二乗する関数squareとマクロm_squareを考える。

#関数定義
function square(x)
  x * x
end

#マクロ定義
macro m_square(x)
  :($x * $x)
end

関数は説明不要だと思うので、マクロである。JuliaのマクロはLispのマクロと見た目がよく似ている。:()で囲むのが、Lispで言うところのバッククォート、$がLispで言うところのカンマに対応する。さて、Lispのマクロはコードを作るのだった。Lispのコードとはリストであった。そのため、Lispでは、マクロはリストを作れば良かった。Juliaではマクロは何を作っているのだろうか?

答えを言おう。Juliaのマクロが作るのは、Expr型のデータである。Expr型のデータとは何か?Juliaの抽象構文木を表すデータである。そうなると、抽象構文木とは何かを話さねばなるまい。

抽象構文木

例えば、 "x = 1 + 2" という式を考える。「変数xに1+2の結果を代入する」という意味である。この式はプログラムコードとして与えられる。文字列の"x = 1 + 2"である。このままではただの文字の並び('x', '=', '1', '+', '2')である。ここから何かの意味を抽出したデータ構造に変換する必要がある。意味を抽出して初めて、機械語に翻訳できてコンピュータで実行できるのである。ここで言う、「意味を抽出したデータ構造」が抽象構文木である。

さて、"x = 1 + 2"という文字の並びから、どのような工程を経て、「変数xに1+2の結果を代入する」という意味を持つ抽象構文木に変換するかは重要な問題である。重要な問題ではあるが、私の手には余るので解説しない。興味のある方は、「構文解析」と言う単語で検索されるとよい。人によっては人生が変わるくらい広く深い世界が広がっている。私はその後の人生は保証しない。入門書としては「Go言語でつくるインタプリタ」という本がおすすめである。

www.oreilly.co.jp

ともかく、構文解析という工程を終えると、"x = 1 + 2"という文字の並びから、「変数xに1+2の結果を代入する」という意味の抽象構文木となる。これを図示したのが下記のイメージである。assignというのは、代入を意味するとしておく。この情報があれば、どの順番で何の演算を行えば、所望の結果が得られるのか一目瞭然である。

f:id:muuuminsan:20201003230504p:plain
"x = 1 + 2"に相当する抽象構文木

画像ではなく、Juliaのテキスト形式で表現すると、次のようになる。headが処理を表し、argsが引数である。つまりこれはassign処理を表す抽象構文木で、引数の1つ目がx、2つ目が別の抽象構文木である。それは、+処理を表す抽象構文木で、引数の1つ目が1、2つ目が2である。

Expr:
 head: assign
 args:
  1: x
  2: Expr
   head: +
   args:  
    1: 1
    2: 2  

そして、この情報を極限まで圧縮すると、LispのS式に表現できる。

(assign 
  x 
  (+
    1 
    2))

いわば、LispのS式は抽象構文木そのものである。

Juliaのマクロ

さて、話を戻そう。JuliaのマクロはExpr型=抽象構文木のデータを返す。Expr型データを作るやり方はいくつかあるが、最もお手軽なのが、下記の:()記号である。この記号で括られたコードは、対応するExpr型データに変換される。なお、:()記号の代わりに、quote〜endというブロックで括ってもいい。(複数行に渡る時は、:()は使えない。)

#マクロ定義
macro m_square(x)
  :($x * $x)
end

#こう書いてもいい
macro m_square(x)
  quote
    $x * $x
  end
end

最初、私はこのやり方を見て、Lispとよく似ていると思った。しかし、LispとJuliaでquoteの使い方はよく似ているが、内部動作は違うように思う。Lispはquote内部の評価を止めるという動作をするが、Juliaの場合は、Expr型に変換するということを行っている。(Expr型のデータは評価を止めるので、結果的には同じような動作をすることにはなる。)

#マクロ呼び出し
m_square(1 + 2)

#マクロ展開の流れ
#まず、引数として渡ってきた1 + 2を評価せず、抽象構文木の状態でマクロ内部に送る。
#xに :(1 + 2) を割り当てる。
#次にマクロ内部の:($x * $x)に代入。
:($:(1 + 2) * $:(1 + 2))
#$記号はquoteを打ち消し、結果、下記のようになる。
:((1 + 2) * (1 + 2))

#値の評価
(1 + 2) * (1 + 2)
3 * 3
9

LispとJuliaで引数の評価をしないという点は同じだ。個人的な感覚だが、Lispの場合は引数として与えられた"(+ 1 2)"がそのまま渡されるイメージだが、Juliaの場合は"1 + 2"を一度クォートで包むようなイメージで捉えた方が理解しやすい気がする。*3そして、クォートを打ち消すのが$記号だ。

なお、Lispのマクロの説明で、Lispのマクロはコードではなく値を返しても良いと書いたが、これはJuliaも同じである。一方、動作させていると少し違いもあった。Lispマクロでは上手く動かなかった下記のケースである。マクロがExprを返さず、引数を評価して2乗した値を返している。このとき、変数を渡している。Lispでは、マクロの呼び出しが最初に展開されるため、aってなんですか?とエラーになったのだった。しかし、下記コードをJuliaで実行すると、9と評価されるのだ。

macro m_square(x)
    y = eval(x)
    y * y
end

a = 1
println(@m_square(a+2)) #結果、9と表示される。 評価前のaを含んだ:(a+2)ではなく、:(1+2)が評価されたということだ。

Lispのマクロはマクロ展開時に評価まで行うが、この動きを見るとJuliaは違うようだ。最初は上から順に逐次マクロ展開、評価を繰り返していくのかと思ったが、「1から始めるJuliaプログラミング」によるとJuliaはマクロ展開は構文解析直後、値の評価前のかなり早いタイミングで行われると書いてあった。合わせて考えると、まずマクロ展開でコード変形のみを行なった後、上から順にコードが評価されているようだ。変数を渡すことができるぶん、Lispよりも使い勝手は良さそうだが、マクロ展開時に評価が走らないので、コンパイル時の計算処理ということは期待できないかもしれない。そうなると、こんな書き方をするメリットはあまりない。まあ、いずれにしろLispと同じく普通に書くようなものではないので、あまり気にする必要はないだろう。

2020/10/08追記: この部分、Lispと動作の違いがあるが、Lispでもlet式に含めるのではなく、別にグローバル変数(defvar a 1)のような式を作っているとエラーにならないと指摘を受けた。

(defvar a 1)
(m-square (+ a 2)) ;エラーにならない

そのため、LispとJuliaの違いというよりは、グローバル変数かどうか、という違いのようである。 letに含まれているときには、letの内部を先にコンパイルしなければ、letそのものを評価できないので、動作に違いが出るのでは、とのことだった。 追記終わり

同図象性とは何か

通常、プログラマの書いたソースコードは、その言語の処理系が勝手に抽象構文木に変換する。とてもざっくりと書くとこのようになっている。*4

ソースコード → 抽象構文木機械語

しかし、言語によっては、プログラマが直接、抽象構文木を作る機能が提供されている。Juliaのマクロはその一つである。プログラマソースコードと抽象構文木を混ぜこぜで書き、言語の処理形が抽象構文木に統一する。

ソースコード + 抽象構文木 → 抽象構文木機械語

プログラマソースコードと抽象構文木を混ぜこぜで書く、というところにネックがある。ソースコードと違い、普通、抽象構文木は人間が書くように設計されていない。抽象構文木を正確に書くのはとても大変だ。JuliaでもExprを愚直に作ることはできるが、あまりやりたくはない。例えば、1 + 2 という処理のExprは、下記のように作ることになる。

Expr(:call, :+, 1, 2)

これを手でコーディングするのはいかにもぎこちないし、普通のコードとも見た目が全く違う。こんなものがソースコードと混ぜこぜで作られていても読みづらくてしょうがない。しかし、Juliaは通常のコードを抽象構文木に変換するための便利な表記を提供している。:()やquote〜end構文だ。これによって、通常のコードとほぼ同様の見かけにできる。

Lispは同図象性があると言われる。LispのコードはS式で表現され、S式はリストであり、リストはLispが得意とするデータ構造なのだ。これは「Lispのコードはデータで、データはコードだ」というような言葉にも表れている。しばしば、この点がLispのマクロの力の根源と言われている。もう一度、下記の図を振り返ってみよう。

ソースコード + 抽象構文木 → 抽象構文木機械語

Juliaはquote構文により、抽象構文木の見かけをソースコードに肉薄させることができた。Lispはどうか?LispのS式は抽象構文木そのものと言っていいのだった。Lispプログラマが手で書くあらゆるコードが抽象構文木のため、マクロが抽象構文木を作っても見かけの違いはほとんどない。LispはS式だからこそ、極めて自然にマクロの力を取り入れることができたのである。これがマクロと同図象性の関係である。

Juliaは逆のアプローチを行った。S式を採用していないJuliaは、抽象構文木の見かけをソースコードに近づけた。これはこれで同図象性を満たしていると言えそうである。少なくともマクロ機能に関しては、かなりのレベルで上手くいっているように見える。Lisp以外でこれほど上手くこなしている言語は、私は他に知らない。(Elixirという言語がJuliaのマクロと似た機能を提供しているように見えるが、Elixirについては勉強不足なので触れない。)

なお、他の言語がJuliaのマクロの真似をすることができるのかどうか、よくわからない。素人考えでは適切なquote構文を導入すれば良いような気もするが、実装レベルでは構文解析のコアの部分に手を加える必要がありそうなので、既存言語に付け足すのは簡単ではないかもしれない。

では次のセクションでは、JuliaとLispの具体的なコードを比較してみることにする。

LispとJuliaの比較

今から作るのは、assert-euqalというマクロだ。今度はm-squareのような説明のためのマクロではなく、マクロでなければできない処理である。

このマクロは評価させたい式と、期待する結果を引数にとる。期待と一致すれば"OK"と表示し、不一致であれば"NG"という情報に加えて、評価した式、結果の値、期待した値を表示する。

こんなふうに使うものだ。

Lisp

(assert-equal (square 3) 9);"OK"と表示される
(assert-equal (square 3) 10);"ERROR! Expr:(SQUARE 3), Expected:10, Actual:9"と表示される。

Julia

@assert_equal(square(3), 9) #"OK"と表示される
@assert_equal(square(3), 10) #"NG! Expr=square(3), Expected=10, Actual=9"と表示される。

なぜこれがマクロでなければ実現できないかというと、関数の場合、引数に渡したsquareが先に評価されてしまい、assert-equalの内部では評価後の値にしかアクセスできないからだ。マクロでこの引数が評価されてしまう前に、文字列に変換してしまい、出力に利用しようという目論見である。

Lispのマクロがこれだ。to-strとstrconcは補助関数で、あまり気にしなくて良い。Common Lispの名前は少々長い傾向があり、見づらいので短縮のためだけに用意した。

(defun to-str (arg)
  (princ-to-string arg))

(defun strconc (&rest lst) ;&restと言うのは可変長引数を意味して、任意の数の引数を受け取り、リストにまとめて直後の引数に渡す。
  (apply #'concatenate 'string lst))
  
(defmacro assert-equal (expr expected-value)
  (let ((expr-str (to-str expr)))
    `(let ((actual ,expr)
           (expected ,expected-value))
       (if (= actual expected)
           (print "OK")
           (print (strconc "NG! Expr:" ,expr-str ", Expected:" (to-str expected) ", Actual:" (to-str actual)))))))
macro assert_equal(expr, expected_value)
    expr_str = string(expr)
    quote
        actual = $expr
        expected = $expected_value
        if actual == expected
            println("OK")
        else
            println("NG! Expr=" * $expr_str * ", Expected=" * string(expected) * ", Actual=" * string(actual))
        end
    end
end

マクロについての細かい説明はしない。ともかく、2つのマクロが非常によく似ていることが感じられるのではないだろうか。マクロの内部には作りたいコードのテンプレートがあり、必要に応じてカンマや$記号で評価していく。上で少し見たように、JuliaはLispと割と異なる機構でマクロが動いていそうである。にもかかわらず、これだけ近い雰囲気のものができているのは、相当の設計の努力があったに違いない。

Juliaの優位性

マクロについて説明しておく必要のある事柄がもう一つある。マクロの変数捕捉という問題である。これはLispプログラマが口を酸っぱくして気を付けろと叫ぶ問題であり、Juliaプログラマは基本的に気にする必要のない問題なのだ。つまり、Juliaの方が優位なポイントである。

正直、この節は書くかどうか悩んだ。Lispのマクロに関する厄介な問題であり、マクロという機能そのものが敬遠されそうな気がするからだ。薄々感づいておられるかもしれないが、私はマクロが好きだ。本当のところを言うと、この記事だって半分はJuliaのマクロにかこつけてLispのマクロを宣伝しようと思って書いたのだ。だが、Juliaのマクロをやる上ではあまり気にしなくても良い問題だし、Lispのマクロをやる上ではどうせ避けては通れないのだ。それに何よりフェアではない気がしたので、載せることにした。

例え話をしよう。あなたがプログラムを書いていて、配列をソートする必要に迫られたとする。困ったあなたを見て親切な同僚が、自分の実家はプログラムの生成業の老舗を営んでいて僕はソートプログラムの作成が得意だとか何とか言って、自分の作ったコードを直接あなたのコードにペタッと貼ってくれたとしよう。どうなるかどうか?あなたはコードでxとかyとか言う変数を使っている。同僚の埋め込んだコードも、xとかyとか使っている。運が良ければ上手く動くかもしれないが、悪ければ動かないだろう。関数を書いて呼び出すようにしてくれたら、こんな心配はしなくて良いのだが、コードを直接貼り付けるものだからこんなことになるのだ。この気の利かない同僚の名前がCommon Lisp家のマクロ君だ。

Lispのマクロの説明を思い出して欲しい。Lispマクロはマクロ展開時に、呼び出し元にマクロの評価結果をペタペタ貼っていくのだった。そうすると、呼び出し元の処理の変数名と、マクロ展開された結果の変数名がバッティングしてしまうという問題が起こる可能性があるのだ。

少々わざとらしい例になるが、下記の例を出そう。これは与えられた引数の中身を交換するマクロだ。prognというのは、上から順番に実行してねという意味の式で、setqというのは代入だ。(setq a 1)でaに1を代入する。

(defmacro swap (x y)
  `(let ((tmp ,x))
     (progn
       (setq ,x ,y)
       (setq ,y tmp))))

このswapマクロは通常は問題を起こさない。(swap a b)と呼ぶと、下記のように展開される。これは上手く動く。

(let ((tmp a))
  (progn
    (setq a b)
    (setq b tmp)))

問題は、(swap a tmp)のように呼び出し元が同名の変数を使っている時だ。

(let ((tmp a))
  (progn
    (setq a tmp)
    (setq tmp tmp)))

これは想定どおりの動きをしない。このように、呼び出し元の変数がマクロ内の変数に意図せず取り込まれてしまう動きのことをマクロの変数捕捉と言う。変数捕捉は厄介な問題だが、幸いにして定型化された手法で回避できる。gensymというものを使うのだ。

(defmacro swap (x y)
  (let ((tmp (gensym)))
    `(let ((,tmp ,x))
       (progn
     (setq ,x ,y)
     (setq ,y ,tmp)))))

gensymを使うと、絶対に他のシンボルと衝突しないことが保証されたシンボルが作成される。マクロ定義上、tmpという文字が見えているが、展開形にはtmpという文字では表現されない。#:G842というのがgensymが用意したシンボルで、これはCommon Lisp処理系が他のシンボルと衝突しないことを保証する。

(let ((#:G842 a)
  (progn
    (setq a tmp)
    (setq tmp #:G842)))

他にも、マクロの変数捕捉にまつわるトピックは色々あるが、興味のある方は「On Lisp」という書籍がおすすめだ。邦訳が無料で公開されている。凄いことだ。ただし、決して簡単な本ではない。

www.asahi-net.or.jp

さて、Juliaのマクロはどうかと言うと、なんとこのような悩ましい問題は気にしなくて良いのだ。Juliaはマクロ内で定義した変数が呼び出し元の変数と衝突しないように保護してくれる。イメージでいうと、Lispのgensymを勝手に適用してくれるような感じだ。Julia家のマクロ君はなかなか気の利いたやつなのである。

そう、Juliaではこのようなマクロを定義してもへっちゃらなのだ。呼び出し元の変数と衝突してしまうことはない。

macro swap(x, y)
    quote
        tmp = $x
        $x = $y
        $y = tmp
    end
end

・・・と言うのは嘘だ!!いや、変数と衝突することがないと言うのは本当なのだが、へっちゃらと言うのは嘘だ。Juliaはマクロに現れるあらゆる変数を保護する。それは、ローカル変数として宣言したtmpだけでなく、引数として与えられたxやyも同様なのだ。これは普通のマクロでは問題にならないが、引数の値を書き換えたいswapのようなマクロを作る時には問題になる。マクロ呼び出しに渡された変数と、内部での変数は、例え$記号を適用しても、別の変数扱いになるのだ。(ちなみに、この処理でswapを呼び出すと、呼び出し元が書き換えられないばかりかUndefVarErrorというエラーになる。私にはこの理由はよくわからない。)

しかし、安心して欲しい。この保護機構を打ち破る処理があるのだ。それがエスケープだ。マクロの外側とあえて関わりを持たせたいときには、esc()で括ってやると良い。外側と関わりを持たせたくない変数はそのままだ。

macro swap(x, y)
    quote
        tmp = $(esc(x))
        $(esc(x)) = $(esc(y))
        $(esc(y)) = tmp
    end
end

この例では、xとyをエスケープしたが、tmpもエスケープしても良い。そうすると、外側にあるtmpと言う変数と干渉する。通常これは避けたいことだが、あえて変数捕捉させるマクロというのもある。Lispマクロの奥義のような位置づけだ。先ほど紹介したOn Lispに詳しく解説されている。Juliaでも、やりたいときにはそれをやる自由は与えられている。

macro swap(x, y)
    #tmpを:tmpとシンボルにしたことに注意。escの仕様。さらにそれを埋め込むための$が必要。
    quote
        $(esc(:tmp)) = $(esc(x)) 
        $(esc(x)) = $(esc(y))
        $(esc(y)) = $(esc(:tmp))
    end
end

macro swap(x, y) 
    #全体をescで括っても良い
    esc(
        quote
            tmp = $x
            $x = $y
            $y = tmp
        end
    )
end

ローカル変数だけでなく、引数まで保護するのは行き過ぎという意見もあるようだ。しかし、私はそうは思わない。そもそも関数であれ、マクロであれ、引数の値を書き換えるのはあまり良いスタイルではない。引数は渡されても値を変えず、返り値で結果を得るのが良いプログラミングスタイルだ。さらに言うと同じ引数を渡すと同じ結果を返すのが良い。参照透過性と呼ばれる性質だ。参照透過性を満たしたコードはとても明快になる。コードは明快さを優先すべきで、効率はその次だ。参照透過性を満たそうとすると、計算効率は落ちる可能性はある。どうしても計算効率が優先なところであれば、参照透過性を崩してもよい。しかし、それは例外であるべきだ。なお、JuliaにはLispから引き継いだ良い慣習がある。引数の値を変更する関数やマクロには、後ろに!記号をつけると言う慣習だ。これで、気をつけるべき箇所が明確になる。

macro swap!(x, y) #引数を変えてしまう処理には!をつけよう
    quote
        tmp = $(esc(x))
        $(esc(x)) = $(esc(y))
        $(esc(y)) = tmp
    end
end

esc()も同様で、気をつけるべき箇所が目立っている。これは良いスタイルだ。

Lispのマクロは、C言語のマクロの文字列組立と少しイメージが似ている。マクロ機能は、あくまでリストを組み立てているだけだ。このような性質から、Lispのマクロは「低水準」のマクロと呼ばれることがある。低水準というのは別に程度が低いとか頭が悪いという意味ではない。C言語アセンブリ言語が低水準言語と呼ばれるのと同じで、あれこれ抽象化の層が挟まっていないと言う意味だ。これと対比して、Juliaのようにプログラマが書いたマクロ定義を処理系があれこれ面倒見てくれるマクロを「高水準」のマクロと呼んだりする。

また、Lispのマクロはデフォルトでは変数捕捉のような問題を引き起こすので、「不健全な」マクロと呼ばれることがある。Juliaのマクロはデフォルトで変数が保護されるので、「健全な」マクロと呼ばれる。

Lispの(決して破られない)優位性

さて、ここまで、Juliaのマクロがいかに優れているかを説明してきた。ここまでのところ、JuliaのマクロはLispと同等以上に思える。書きやすさ、読みやすさ共に遜色がないし、健全だ。必要であれば、不健全なマクロと同等のこともできる。

では、Lispのマクロにはもう一つも良いところはないのだろうか?Lispは見た目ばかり奇妙なくせに能力も劣っているトンチキ野郎なのだろうか?いや、そんなことはない。Lispでなければ表現できないマクロは、やはりあるのだ。

こんな例を考えよう。あなたはあるプロジェクトに携わり、多くの関数を手がけてきた。最高技術責任者としての栄誉と名声を欲しいままにしているスーパースターだ。そんなあなたがある問題に直面している。どうにもプログラムの実行速度が低下しているのだ。原因を究明する必要がある。

こうなると、ボトルネックを特定するために関数の処理の最初と最後にログを出力したくなるのは自然な流れだ。しかし、目星をつけた関数の処理の最初と最後に片っ端からログを出力する処理を書くというのはいかにもダサい。あなたはスーパースターなのだからもっとエレガントに解決しなければならない。でなければ来期の人事異動で最低技術責任者あたりに降格させられてしまうだろう。クルーザーを手放す羽目になるかもしれない。

そう、そんなときに助けになるのがLispのマクロなのだ。何もあなたがひたすらログ出力処理を書き込む雑用みたいな仕事をする必要はない。部下にさせる必要もない。マクロにさせれば良い。あなたが発明するのは、defun-with-logマクロだ。

(defmacro defun-with-log (funcname args &body body)
  `(defun ,funcname ,args
     (progn
       (start-log (string ',funcname))
       ,@body
       (end-log (string ',funcname)))))

このマクロはこのように呼び出す。

(defun-with-log add (a b)
  (print (+ a b)))

なんと、これはマクロ呼び出しというよりは、関数定義に近いように見える。見えるが、実際にはマクロ呼び出しだ。つまり、defun-with-logというマクロを、
第1引数 funcname : add
第2引数 args : (a b)
第3引数 body : (print (+ a b))
で呼び出しているのだ。

第3引数の前にある&bodyは、&restと同じで、ここには可変長の引数を渡すことができて、リストにまとめて後ろの引数に入れときますよと言う意味だ。ただ、このような形で関数の本体を受け取ることが多いので、特別に定義されている。エディタが気を利かせてくれることがある。 もう一つ解説すると、,@bodyというのは、bodyのいう変数はリストで、リストの中身を引っ張り出してカンマを作用させなさいという意味だ。

このマクロを展開した結果、マクロはdefunから始まるリストを返す。リストは結果的には関数の定義となっている。マクロ展開後の形を下記に示す。

(defun add (a b)
  (progn
    (start-log (string 'add))
    (print (+ a b))
    (end-log (string 'add))))

start-log とend-logは一応実装しておくとこのような形だ。開始、終了のログを関数名を含んだ形で出力する。実際には時刻を出したりするだろうが、今回話したいのはそこではないので手抜きをした。

(defun start-log (funcname)
  (print (strconc funcname ": write start log")))

(defun end-log (funcname)
  (print (strconc fucname ": write end log")))

これで完成だ。通常の関数呼び出しのように、(add 2 3)とでも呼び出してみる。

(add 2 3)
;次のような出力が得られる。
"ADD: write start log"
5
"ADD: write end log"

あとは、ログを出したい関数のdefunをdefun-with-logに変えてやれば良い。なんなら一括置換すれば全ての関数のログを出すこともできる。

こうして華麗に問題を解決したあなたは、さらにボーナスを得ることができた。これで別荘を買う計画を立てることができるのだ。クルーザーにも乗りたいので海辺がいいだろう。

さて、Juliaで同じことができるだろうか?私にはできなかった。頑張ってこのようなものは作れた。

#Julia版のwith_logマクロ
#Lisp版とは動作が異なり、start_logとend_logの間で与えられた関数を呼んでいる。
macro with_log(func)
    s = string(func.args[1]) #関数のExprのarg[1]には関数名が入っている。
    quote
        start_log($s)
        $func
        end_log($s)
    end
end

Lispと同様、start_log, end_logも一応示しておく。

function start_log(func_name)
    println(func_name * ": write start log")
end

function end_log(func_name)
    println(func_name * ": write end log")
end

使い方はLispと違い、このようになる。

function add(a, b)
    println(a + b)
end

@with_log(add(2, 3)) 
#次のような出力が得られる
add: write start log
5
add: write end log

悪くはない。関数本体には手を加えずにログを出力している。しかし、ログを取りたい関数呼び出しのある箇所全てに、@with_logをつけて回る必要がある。これではボーナスを得るのは難しいかもしれない。

JuliaでLisp版のようなマクロを書くには構文レベルでの困難さがある。Lisp版では、関数定義するがの如く、マクロ呼び出しをすることができた。これは関数定義とマクロ呼び出しの構文が非常に似ていることで初めて実現できる。どちらもS式なので、マクロ呼び出しで与えられた引数をどこにどう配置したら関数定義に変形できるのかが容易にわかるのだ。Juliaはそもそも関数定義の形とマクロ呼び出しの構文が全然違うので、うまくいかない。真似て書くなら下記のようになるが、実装をどうすればいいか私にはわからないし、呼び出し側も普通の関数定義とは違った、ぎこちないものになるだろう。

macro function_with_log(funcname, args, body...)
   #???
end

これがLispの誇る究極の同図象性だ。S式だからこそ、このような芸当が自然にできるのだ。しかし、S式に採用すると、それはLispになってしまうのではないか?これがLisp族とマクロの切っても切り離せない関係なのだ。

まとめ

長々とLispマクロとJuliaマクロの比較を行ってきた。マクロのみの対決で言うと私はLispに軍配を上げる。最後に意地を見せてくれた。まさにLispの真骨頂だ。

とはいえ、Juliaにも素晴らしい点が多くある。いや、素晴らしい点だらけだ。正直、Lispの構文はとっつきやすいとは言えない。慣れたらあまり気にならなくはなるのだが、それでも私自身、Juliaの方が読みやすいと思う。私はLisp数値計算プログラムをうまく書ける自信はないが、Juilaならずっと上手くやれそうな気がする。

マクロ対決だって、私はLispびいきなのでLispを勝者にしたが、審判次第ではわからない。マクロも健全という利点もあるし、最後のようなケースを除いて、実際にLispで使われるマクロの9割以上はカバーしているのではないか。となると、Juliaを勝たせる人がいてもおかしくはない。

そして、Juliaを彩る数々の現代的な機能たちだ。私はまだそれらの機能を全く使っていない。まだマクロを少しかじっただけなのだ。これからJuliaのいろいろな機能を触ってみたい。きっと楽しいプログラミング生活が待っていることだろう。

2020/10/10 追記

この記事には続きがある。twitter等で色々指摘を受け、結論を再考したのだ。ぜひ続きを読んでほしい。

muuuminsan.hatenablog.com

追記終わり

*1:構文ではない単なるリスト(数値の並びなど)を表現したいときには、リストの前にシングルクォートをつけるなどして、明示的に表現する。

*2:実際のLisp処理系がこのような動いているのかはわからない。というか、おそらくもっと効率的に処理されていると思う。しかし、そのようなイメージで動く結果と一致するようにはなっているはずである。

*3:Lispの場合は、引数に"(+ 1 2)"が与えられているが、Juliaの場合は引数は括弧の中身の"1 + 2"が与えられているように見えるからかもしれない。まあこの辺りは個人の感覚なので、あまり深く考えすぎない方がいい。結局このあたりはたくさんマクロを書くと慣れてくる部分だ。

*4:Juliaはコンパイル時点では中間コードを作り、最終的な機械語は実行時に作るが、細かいので省いた。

つるの剛士氏が巻き込まれた外国人差別騒動について

つるの剛士氏が自身のtwitterで、「栽培しているパクチーを盗まれた、どうやら犯人は外国人のようである」という趣旨の投稿を行ったのをきっかけに、外国人差別的であると大変な騒動になっている。

 

今やtwitter上はつるの氏が人種差別主義者か否かで両陣営真っ二つに分かれ、その断絶は底を窺い知れないくらいに深まっている。なぜこのようなことになってしまったのだろうか?

 

始めに断っておくと、私はこの件については、つるの氏に非常に同情的な立場である。

しかし一方で、批判者らの意見にも無視してはならない点があるようにも感じている。

両者の意見を比較することで検討していきたい。

 

つるの氏の発言は外国人差別的か?

つるの氏の発言を確認してみよう。

まず、前段に農林水産省の公式アカウントによるツイートがあった。

下記の文章である。

”【ご注意ください】生産者の皆さまが手塩にかけて育てた家畜や農作物、トラクター等の機械の盗難被害が発生しています”

それを引用する形でつるの氏がこうツイートしている。

"うちの畑も最近パクチーやられました(現行犯でしたが※「日本語わからない」の一点張り)ので気をつけてください。悲しいですが監視カメラ取りつけました"

それに続いて、フォロワーへの返信という形で、以下のようなツイートも行っている。

"一応目星がついていますので。畑近くの工場で働いてる外国人。もちろん次見つけたら通報します"

 

この後つるの氏は元新潟県知事の米山隆一氏から外国人差別的な発言を行っているとして、批判を浴びることになる。

 

米山氏の発言

"そのパクチーを取った人が外国人だとして豚泥棒が外国人と言う証拠はなく、実家が養豚農家の身として言うなら、大量に屠殺する手段を持たない外国人がこれを行うのはかなり困難で、むしろ同業者の可能性が高いと思われます。まるで外国人の犯罪かのように示唆してRTするのは極めて差別的だと思います"

 

唐突に豚泥棒という単語が出るなど、一見すると意味不明のツイートだが、これについてはあとで詳しく見ていく。大事なのは後半部分である。要するに、つるの氏の発言を差別的であると批判しているのである。

 

もう一人、映画評論家の町山智浩氏という人物もつるの氏を声高に批判しているのであるが、声高なだけで特にみるべきところはないのであまり気にしなくても良い。

 

さて、ここまでの流れを見たところ、私には特につるの氏に人種差別的な意図を持った発言には思えない。あくまで個人的な盗難被害を訴え、犯人が外国人と思われるという趣旨の、事実に沿った発言をしているだけである。

つるの氏の発言は差別的ではない。

対して、米山氏の批判は言いがかりと言われても仕方がない。

もっと率直に言えば、意味不明、無茶苦茶である。

米山氏は何を言いたいのか?

もちろん輝かしい経歴を持つ米山氏がそんな無茶苦茶なだけの話を展開するわけがない。

米山氏のその後のツイートを見ていこう。

"豚の屠殺って結構えぐくて、心臓が動いている状態で頸動脈を切って血抜きをしなければいけません。更に無事屠殺ができたとして解体の手間は非常に大きく、それができたとしても大量の骨や皮の廃棄物がでます。1頭2頭ならともかく600頭700頭を解体するのは、余程の設備が無いと不可能です。"

"一方同業者なら、自分の豚舎で生育する事は非現実的ですが、自分の豚舎で育てた豚だと言って屠場に出荷すれば、それが盗んだものだと見抜くのは容易ではありません。勿論真相は不明が前提ですが、現時点で外国人と決めつける証拠も根拠も極めて希薄であると、私は思います。"

しばらく豚の話が続くが、これはつるの氏が農林水産省の注意喚起のツイートを引用したためであろう。米山氏は事前に、日本で深刻な家畜の盗難被害が相次いでいるという前提知識を持っていたのだろう。そして、実家が養豚農家であり、また、新潟県知事も務めた経験から、そのことに強い問題意識を抱いていたのではないだろうか。

そこに、つるの氏が自身のパクチー被害と絡めて外国人の関与を示唆した。「日本における家畜や農産物の被害が外国人によるものである」と誤解させる発言を行ったと解釈して、差別的であるとの批判に至ったのだろう。それはその後の米山氏のツイートから確認できる。

"そうですね、そういう明らかに「豚泥棒は外国人」と思わせる書き方でありながら、しかしそれを指摘されたら「気を付けろと言っただけ」と言えるように書き方をごまかしているのが丸分りなのが、私は非常にげんなりします。二重三重に格好悪いだろうと。"

 

ここに米山氏の勇み足がある。つるの氏は農林水産省が警告し被害の具体例としてパクチー被害を報告しただけである。一方、米山氏はつるの氏がその被害全てを外国人の仕業であるかのように喧伝したと誤解した。これが米山氏の第一の誤りである。

 

つるの氏の発言は外国人差別を助長するか?

ここまでは、米山氏には何一つ良いところがなかったが、ここから少し風向きが変わってくるのである。

引き続き米山氏の主張を見ていこう。

"何の証拠もなく、かつ状況からはむしろ日本人の犯行である可能性の方が高く見える事言ついて、影響力のある有名人が予断で「外国人の犯行ではないか?」と示唆する発言をし、その結果、何の関係もない外国人を一般の人が疑いの眼差しで見つめ、それを多くの人が看過するような事です。"

これは、「米山さんの言う『差別』って何ですか?」と言う質問に対する米山氏の回答である。

 

さらに次のような主張も行っている。

"私のTWは、つるの氏のパクチーの被害報告は良いとして、わざわざそれを、昨今の豚泥棒への注意喚起の農水省のTWのリプライでTWすることで事実上豚泥棒が外国人の犯行と匂わせ、氏のレスへの対応を含め差別意識を煽っていますよねと言う事です。日本語が分からない日本人いらっしゃいますよね。"

最後の一文は自分の主張を理解しない人々を煽っているように見えるが、実際には「日本語のわからない日本人なんているのですか?」と言う質問をした質問者への回答である。

 

そしてついに、この(わかりづらい)例えが登場するのである。

"例えば私が「この前うちの米を盗んだ人がいて、捕まえたらツーブロック大阪弁で『知らへん、やってへんがな。おら、何見てんじゃ!』と言うだけ。武士の情けで今回は許したけど皆気をつけよう。」と書いたら、例え事実でも、実際問題ツーブロック大阪弁に対する差別を煽ることになりますよね?"

 

良識溢れるtwitter上では、何を言っているのだと笑い飛ばしたくなる例えだが、例えばこれを某匿名掲示板に書き込んだらどうだろうか?そこでは大阪はたいそう評判が悪い。

また大阪か」「大阪なら仕方がない」「大阪人としては標準的な人物」

そんな会話が交わされるのが目に見えている。

これは、そのコミュニティの文化に、大阪への蔑視が根付いているからである。

事実として、大阪は犯罪の発生率が高い。浮浪者も多い。言葉遣いは東京と比べると乱暴に聞こえる。

そう言った事実をもとに、「だから馬鹿にして良い」と言う風潮が根付いている。

これは一種の差別である。

そして、新たな「大阪人による被害」が報告される度に、その差別的風潮はますます強化されていく。例え事実であっても、いや、事実だからこそ、強化されていくのである。

 

今一つピンと来ない方のために、別の例を出そう。

例えばあなたが江戸時代に生きていて、あなたの村が農産物を盗まれる被害に悩まされていたとする。そこで誰かが、穢多や非人(と呼ばれていた被差別層に属する人)に野菜を盗まれた、と報告する。

どうなるだろうか?奴らは薄汚い盗人集団だと、ますます差別が強固になっていく様子が想像できないだろうか?

 

差別が存在するとき、被差別者層に属する人々に対する悪評は、差別を強化する材料となりうる。

つるの氏のツイートは、事実を単に表明しただけではあるが、しかし、外国人に対する差別があれば、それを助長する可能性はあるのである。米山氏はそのことを憂い、指摘しているのだ。

つるの氏の発言は、それだけでは差別的ではないが、存在する差別を助長する可能性はある。

 

つるの氏は差別主義者か?

米山氏の最初のツイートをもう一度見てみよう。

"そのパクチーを取った人が外国人だとして豚泥棒が外国人と言う証拠はなく、実家が養豚農家の身として言うなら、大量に屠殺する手段を持たない外国人がこれを行うのはかなり困難で、むしろ同業者の可能性が高いと思われます。まるで外国人の犯罪かのように示唆してRTするのは極めて差別的だと思います"

米山氏の第二の誤りはここである。文字数の関係かどうかわからないが、つるの氏に対して「極めて差別的」と発言している。先ほど見たように、これは誤りである。

その他、つるの氏が差別主義者であると言う発言を行う人物が多いが、これらの人物の主張もまた、誤りである。発言内容から判断して、つるの氏は差別的発言をしていないので、差別主義者とは言えない。(もちろん、つるの氏が内心としては差別主義的な思想を持っている可能性はあるが、発露はしていない。)

 

なぜここまで意見が割れるのか?

多くの人の意見が割れている。一方の陣営はつるの氏を差別主義者だと罵り、もう一方の陣営はその指摘は全くの誤りだと言う。米山氏がいくら言葉を尽くして差別の煽動の可能性を指摘しても、全く響かない人々もいる。

現代の日本における外国人は、先ほど例に出した江戸時代の被差別層のような明らかな弱者ではない。外国人差別はおそらく存在するが、多くの人の頭を常に占めていると言うほどではない。

つるの氏の発言は、存在する外国人差別を助長する可能性はある。しかし、「存在する」と全員が感じるほどには、外国人差別と言うものは明白にはなっていない。

そこが人によるこの問題の捉え方の反応の差なのではと思う。

 

つるの氏は発言してはならなかったのか?

非常に重大なポイントとして、「事実を述べるというだけでもダメなのか?」と言う問題がある。つるの氏に特段の落ち度があったとは思えない。落ち度があるのは、つるの氏を差別主義者と一方的に断罪した人々にある。

これは多くの人々に恐怖を与えている。事実を述べただけで、差別主義者のレッテルを貼られてしまう。何の発言もできない世の中になってしまう。

現に、欧州では、移民が犯罪を犯しても差別的だと批判されるのを避けるあまり、公的機関が沈黙すると言う状態が発生しているようである。

左派はなぜケルンの集団性的暴行について語らないのか(ブレイディみかこ) - 個人 - Yahoo!ニュース

例えば英国では、サウス・ヨークシャー州ロザーハムでパキスタン系移民のギャングたちが約16年間にわたって実に1400人の十代の子供たち(最も年少で11歳)をレイプしたり、監禁したり、強制売春させていたことが明らかになって大きなニュースになったことがある。これだけの大規模な犯罪だけに、地元ではみんな薄々知っていたが、ムスリム・コミュニティーは英国人コミュニティーからのレイシズム攻撃を恐れて沈黙していたし、警察も「セックスは合意の上」として少女や親たちからの通報をまともに取り合わなかったと言われているムスリム・ギャングに囚われた自分の娘を奪回しに行った英国人の親が、逆にレイシズム攻撃をしたとして逮捕されたケースもあったという。

明らかに異常事態である。

 

「弱者とされる層からの被害を正当に訴えるだけで、弱者への差別の加害者となってしまう」のである。

果たしてどうするべきなのだろうか。私はこの問題に対する答えを持っていない。おそらく誰も持っていないだろう。

ただ、確かに言えることは、今回つるの氏を攻撃した人々のやり口は明確に間違っていたと言うことだ。

このように難しい問題なのだから、皆が少しずつ歩み寄って妥協点を見出すしかない。そもそも問題と認識していない人たちも大勢いるのだ。本来は、そう言う人々に対し、問題意識を広め、啓蒙し、より良い世界を目指そうと言うべきなのだ。

にもかかわらず、差別への問題意識を持っている当事者たちの多くは、レッテル貼りを行い、ひたすら、つるの氏に対する人格攻撃を行うに終始している。これでは歩み寄りなどできようもない。北風と太陽の教訓を思い出すべきである。攻撃するだけではダメなのだ。