👑

Nimで知る「オブジェクト指向をする/しない」ということ

2023/09/07に公開

Nim は、「もしアラン・ケイがオブジェクト指向と言わなかったら」という歴史の if を感じさせてくれる言語だと思った話をします。

私自身は Nim 初心者です。細部の「こいつ慣れてないな」感はご容赦ください。この記事は、この言語については初心者だけれど、プログラミング言語とパラダイムを考えるうえでとても価値があると思った気付きがあったのを、図々しくも記事にしました。複数のプログラミング言語を歴史的な観点で評価するうえで、Nim を通して 70 年代以前の言語と 80 年代以後の言語、具体的には、C with classes と C++ の境界線を見つめ直すことができるんじゃないかと思います。

ズバリ言うと、Nim はアラン・ケイのオブジェクト指向が通じない言語です。

Nim の言語標準には class キーワードがありません(マクロを作れば語句の拡張は可能ですがオプションです)。が、そんな記法的な特徴が Java のような言語と似ていないからといって、「非オブジェクト指向だ」と言いたいわけではありません。記法の違いは本質的な問題ではありません。最初にケイが「オブジェクト指向」と呼んだ Smalltalk-72 には、Simula から C++ / Java へと受け継がれたクラス(元祖)のアイデアと同じしくみがありませんでした。

記法的な特徴をオブジェクト指向だと言うのなら、Nim は、データと振る舞いの組み合わせを、C ライクにも Java ライクにも記述することができます。以下のように、記法上の語順が入れ替わるだけのものは、パラダイムとは無関係な、単なるシンタックスシュガーです。

type SomeOject = object
    someProp: string

proc doSomething(self: SomeObject): string = self.someProp & "!!"

let so = SomeObject(someProp: "Hello")
doSomething(so)  # Hello!!
so.doSomething() # 同じ
so.doSomething   # 同じ

歴史を遡って事実になかった解釈をし、Simula から C++ への流れのことを実はオブジェクト指向だったのではと再定義する人もいます (日本語 Wikipedia の Simula の解説では、英語版の "is considered" のニュアンスがなくなっていて「である」と断定的に書かれしまっている) が、 少なくともケイは、Simula には存在しなかった (C++ の前身 C with classes にも引き継がれていなかった) アイデアに、改めて「オブジェクト指向」というオリジナル概念の名前を付けたはずなのです。本記事では、Simula の class をオブジェクト指向の本質に含まないとする考え方を採用します。

含まないことを整理しておきます。Simula の class は「振る舞いを持つ」「継承可能な」「抽象データ型」を意味します。この範囲の言語機能に関しては、完全に Nim のカバー範囲です。

type Animal = ref object of RootObj
method sound(self:Animal): string {.base.} = ""

type Duck = ref object of Animal
method sound(self:Duck): string = "🦆 < quack"

type Dog = ref object of Animal
method sound(self:Dog): string = "🐕 < bow!!"

for animal in @[new Duck, new Dog]:    # --> seq[Animal]
    echo animal.sound
🦆 < quack
🐕 < bow!!

method ... {.base.} が、継承によってオーバーライド可能なメソッドになります。Duck と Dog はともに Animal とみなし、sound を呼び出せます。限定的な状況を仮定すれば、これでも十分にオブジェクト指向と等価なものとみなすことができ、ケイの言ったことと違わないように見えます。

この様子を包含する、もうひとつ広い一般化を考えてみましょう。Ruby コミュニティが好んだダックタイピング、つまり「アヒルに見えるものはアヒルのように鳴かせることができる」を考えてみます。ここに、「ケイのオブジェクト指向」と私が言っていることの取っ掛かりがあります。

Ruby と Smalltalk には変数の型制約がありません。型注釈のない Python や、JavaScript も同じです。それらは、「メソッド名が一致するなら同じように扱ってもいい」と言える言語です。これに「構造的部分型」というアイデアで型注釈を付け、静的型チェックを可能にしたのが、Go と TypeScript の interface です。Smalltalk と Objective-C ではプロトコルと呼ばれるものです。

いっぽう、C++ は型制約がないとはじめから成り立たない言語です。「動物としてのアヒル」と同時に、「アヒルのような音が出るもの」の一種でもあると、オブジェクトに型付けををしないと、「オモチャのアヒル」を同じように見立てることができません。そのために、C++ は多重継承を用います。Java はこれを、単一継承とインターフェースに分けました。同じアプローチは Python でも可能です(現在 Python は、継承によるトップダウンな記名的型付けと、構造的部分型の考え方を両方サポートします)。

いずれにせよ、単一継承以外の枝がないと、オモチャのアヒルを本物のアヒルの代わりにすることができません。「枝とは?」を Python で例示すると、要するにこういう形のことです。

class Animal(ABC):
    pass

class Soundable(ABC):
    @abstractmethod
    def sound(self) -> str:
        pass

class Duck(Animal, Soundable):
    def sound(self) -> str:
        return "🦆 < quack"

Nim では、「アヒルのように鳴く」特徴 Soundable を一貫して扱うために、次のような方法を取ることができます。

type Soundable = object
    soundProc: proc():string

proc sound(self:Soundable): string = self.soundProc()

proc soundAll(soundables: seq[Soundable]) =
    for s in soundables:
        echo s.sound

この例は、Qiita の記事を参考にさせてもらいました。(ちなみに、この記事と違って proc sound を再定義しているのは、利用時にカッコを省略したとき、実行ではなく関数の取得になってしまう一貫性のなさを避けるためです。)

https://qiita.com/souchan-t/items/ac974bbbc22348bb5fff

Python の例では、型互換性のための「みなしインターフェース」として付けられた型が、Nim では、物理的に実体化された別のものになっています。オブジェクトそのものがインターフェースでもある、と見るのではなく、オブジェクトから共通した形状の端子を実際に引き出してくるイメージです。

ともかく、まずは具象に依存しない抽象で完結させて安定、具象は後でバインドが設計の基本です。Soundable に関するこの部分は、独立して成り立っていますね。

では次に、それを前提として(つまり依存して)、Animal から Soundable 端子を取り出す方法を作ります。

import std/sugar
# インライン関数を => で楽に書くために

proc asSoundable(self:Animal):Soundable =
    Soundable(soundProc: () => self.sound)

let s:Soundable = (new Duck).asSoundable
s.sound

こうすることで、Soundable という抽象だけを扱うレイヤーと、Animal シリーズの実体とを、分けて考えることができます。

もし C++ の中身を少し知っている人がいたら、この soundProc を持つ Soundable が、まるで仮想関数テーブルのように見えるのではないでしょうか。なんだか、C++ の virtual が指す、実装への関数ポインタのような印象を受けます。

わざわざそうした面倒なものを手作りしたとしても、Nim は非常に記述性に優れた言語なので、作った仕組みを次のようなスマートなコードで扱えます。

import std/sequtils
# map() 関数で記述を短くするために

@[new Duck, new Dog].map(asSoundable).soundAll

さて、これはオモチャのアヒルを含む継承ツリーでも有効でしょうか...?

type Toy = ref object of RootObj
method sound(self:Toy): string {.base.} = ""

type ToyDuck = ref object of Toy
method sound(self:ToyDuck): string = "🤖 < quack"

type ToyDog = ref object of Toy
method sound(self:ToyDog): string = "🤖 < bow!!"

proc asSoundable(self:Toy):Soundable =
    Soundable(soundProc: () => self.sound)

@[new ToyDuck, new ToyDog].map(asSoundable).soundAll
🤖 < quack
🤖 < bow!!

まったく継承関係のないオブジェクトを、同じインターフェースで扱うことに成功しました。多重継承や構造的部分型なんてなくても、何の問題もないじゃないか... とも思えそうです。

が、まさにここに、オブジェクト指向というパラダイムと、オブジェクト指向でないものの決定的な差が潜んでいます。オブジェクトそれ自体が呼び出しに応えるかそうでないかの違いを、なぜそれほど深刻にとらえる必要があるのでしょうか。

考えるとっかかりはここです。Nim では、この文がエラーになります。

let all = @[new Duck, new Dog] & @[new ToyDuck, new ToyDog]

Nim のジェネリクスは非常に限定的で、seq[Animal|Toy] を定義できないのです。seq[T] の型パラメーター T は、いずれかの完全なコンクリート型でなければなりません。何かジェネリックな曖昧さを持ったものでやろうとした私の試みは、ことごとくうまくいきませんでした。

かといって、他の多くの言語のように seq[Soundable] とするのでは、モノそれ自体の入れ物にはならず、やりたいことを満たしません。Soundable からはもはや、導出元の型情報が失われています。それは、Java などでいう Array<Soundable> が、直接 Animal や Toy を混ぜて格納できるものだ (そして取り出されたものそれ自体が Soundable.sound() に応じる) という特徴との、明確な違いになります。

「それ自体」を同じとみなす、というのは、TypeScript で擬似的に表すと、つまりこういうようなことです。

const animals = [new Duck(), new Dog()]
// Animal[] 用の関数に使える

const toys = [new ToyDuck(), new ToyDog()]
// Toys[] 用の関数に使える

const all = (animals as Soundable[]).concat(toys as Soundable[])
soundAll(all)
// これは Soundable[] 用の関数

Nim は、インターフェースのような、異なる種類のものを同じ型とみなすための別系もなく、また、異なる型のオブジェクトをユニオンとしたひとつのジェネリック型で混在させる方法も(標準には)持ちません。同じようなことをしたい場合、明示的に種類分けをするラッパーを設ける、object variants という方法が取られます。

type
    SoundableVariantKind = enum skAnimal, skToy
    SoundableVariant = object
        case kind: SoundableVariantKind
        of skAnimal:
            animal: Animal
        of skToy:
            toy: Toy

proc asSoundableVariant(self:Animal): SoundableVariant =
    SoundableVariant(kind: skAnimal, animal: self)

proc asSoundableVariant(self:Toy): SoundableVariant =
    SoundableVariant(kind: skToy, toy: self)

let animals = @[new Duck, new Dog]    # --> seq[Animal]
let toys = @[new ToyDuck, new ToyDog] # --> seq[Toy]
let all = animals.map(asSoundableVariant) & toys.map(asSoundableVariant)
# --> seq[SoundableVariant]

SoundableVariant は、kind によって何が含まれるかが分かれるコンテナです。この kind はいわば、コンパイルによって失われる実行時型情報を、再度自力で持つようなことです。こうして作ったseq[SoundableVariant] 型の all は、seq[Soundable] の場合とは異なり、オブジェクトそのものを含みます。なので、そこから取り出して、再び Animal なり Toy なりとしての扱いも可能です。

SoundableVariant 内の、それぞれ異なる型の実体を使い、共通した Soundable を得る関数はこう実装できます。この関数の入出力は、実装実体のバリエーションを完全に隠蔽していますね。

proc asSoundable(self:SoundableVariant): Soundable =
    case self.kind
    of skAnimal:
        self.animal.asSoundable
    of skToy:
        self.toy.asSoundable

let s1:Soundable = (new Duck).asSoundableVariant.asSoundable
let s2:Soundable = (new ToyDuck).asSoundableVariant.asSoundable
s1.sound
s2.sound

苦労しましたが、これで TypeScript で示した例と、かなり近い記述方法を得ることができました。

let animals = @[new Duck, new Dog]

let toys = @[new ToyDuck, new ToyDog]

let all = animals.map(asSoundableVariant) & toys.map(asSoundableVariant)
all.map(asSoundable).soundAll
🦆 < quack
🐕 < bow!!
🤖 < quack
🤖 < bow!!

ちなみに、Python で同じことをやってみたのが次のコードです。ここまでの流れと比べて、かなり考えることが少ないのが見て取れます。

https://gist.github.com/tanakahisateru/1fd3ad7bac12d3dd7efb7fa7697bcbe5

さて!!

問題はここからです。

「オモチャのアヒルでも Soundable であれば代替可能」になったことで得られたメリットのひとつは、プログラムの場合分けを減らせたことです。最終的な Nim コードは、配列を合体させてえいやと全体命令をするだけになりました。

が、オブジェクト指向プログラミングをベースにすると得やすい、もうひとつの別のメリットを得ることはできていません。「開放閉鎖原則 = Open Closed Principle」です。

仮に「アヒルに見える宇宙生物」という拡張が必要になったとき、TypeScript, Python, Java, PHP, Ruby, Go... いろいろな言語で、どんな作業が必要になりそうかを想像してみてください。Soundable インターフェースを満たす(型チェックがなかったとしても、きっちり同じメソッドを持たせて)新たな宇宙生物シリーズを作り、既存の実装に何も変更を加えずに追加するだけで済みそうな気がしませんか。

ところが、Nim で取った方法では、既存コードの enumcase of に変更を加えないと、追加された宇宙アヒルをシステムに参加させることができません。どうしても既存コードの変更ができない場合、別の object variants を作り直す必要があります。ちょっとモヤモヤしませんか。

開放閉鎖原則とは、オブジェクト指向プログラミングの原則(と初期は言われたが、今厳密に言うと、オブジェクト指向な言語を使って説明するとわかりやすい、広い意味でのソフトウェア設計の原則という感じ)として有名な SOLID のひとつです。簡単に言えば、「拡張にはオープンで、変更にはクローズド」であるべきだという教えです。もう少し詳しく言うと、既存コードを何も変更せずに、宇宙アヒルを増やせる設計がいいぞ、というか、既存コードそのままでいいからこそ、追加的な拡張が簡単だと言える、というのが自然に言えるパラダイムがいいよ、という論理です。

Nim は何が得意で何が不得意だったかを考えることで、逆に、近年よく使われてきた言語の特徴が浮かび上がります。「オブジェクトそれ自体が、データ型の分類に関係なく、プロトコル(インターフェース)を満たしてメッセージ(メソッドコール)に応えられる」ということが、オブジェクト指向かどうかにとっての、本質的な分水嶺と言えるのではないでしょうか。継承可能な振る舞いデータ型は、全体の中のごく一部の状況をカバーしているにすぎないと言えます。

おっと...!!

話はここで終わりではありません。

そうか Nim は不便な言語だったんだな、でいいんですか? 全くそんなことはありませんよね。Nim は記述性と構文拡張性の高い、素晴らしい言語ではないですか。

継承で済む領域では Nim には何の問題もありませんでした。そこまで複雑なデータモデルを扱うのが目的なら、どうして Nim のような C 言語トランスパイラを選ぶのですか? コンパイル後にほぼ生の C 言語だけになる Nim にとって、オブジェクト指向のために Java のような大きな言語ランタイムを抱えることに、何のメリットがあるでしょうか? Nim は、Python と Ruby を参考にして記述性の高さに全振りしたけど、実質生の JavaScript と同じものだった、CoffeeScript を連想させます。

プログラミング言語を評価するとき、流行っているかどうかや、見た目が気持ちいいかは、決め手になりません。本質的な向き不向きの見極めが重要です。フレームワーク、ライブラリ、ミドルウェア、なんでもそうです。Nim は、オブジェクト指向だとか開放閉鎖原則だとかが常にうまくいくかという心配よりも、圧倒的な記述性とネイティブ速度が欲しいかどうか (あるならモノリスでもいい) で選ぶ言語だと思います。

C 言語のご先祖に、リッチな言語仕様すぎて完成しなかった CPL がありました(実物を見たことはない)。アラン・ケイがオブジェクト指向をひらめくより、ずっと前の言語です。C は当時のハードウェアの現実を見て、CPL,BCPL,B と削ぎ落としまくった結果残ったものでした。もし C の正常進化が、再び CPL のリッチさを取り戻すとしたら、きっと Nim のようなものに違いありません。そんなふうに思わせるぐらい、Nim は 70 年代のプログラミング言語、「あらゆる部分がメッセージング」というオブジェクト指向の概念がまだこの世になかった頃のものの、直系の進化系を思わせるのです。CPL の関数は名前と本体を = で結び、result is で結果を示す構文でした。ほら、なんだかそれっぽいですよね...

サンプルコードの中で、Soundable は仮想関数テーブルに見えることに気付きました。また、実行時型情報の保持とそれによるディスパッチを自力で持つ object variants を見ました。これはまるで、83 年にそういったものを言語機能として自動化した C++ の誕生 (そして Smalltalk から用語を拝借してこの差分をオブジェクト指向と呼んだ) 直前に、C (with classes も含む) プログラマーが共通してやっていたことなんじゃないだろうか、と感じました。

私は、この記事で紹介した Nim の設計パターンを、良いものとして紹介したつもりはありません。むしろ、Nim ではこのような状況を避けられるときは、可能なかぎり避けるべきだと考えています。「オブジェクト指向をしない」ことがベターな選択になる場合に向いた言語の、典型的な例だと思います。

そもそもプログラミング言語は、問題に対してそれぞれ全く別のパラダイムを提供するものです。SQL は関係データの集合を扱う「言語」ですし、関数型言語は ALGOL からの進化系統と全く別の存在です。オブジェクト指向があらゆるプログラミングの万能薬だと信じても、ろくなことはありません。オブジェクト指向にはオブジェクト指向のメリットがあり、そいつを意識して活用するからこそ役に立つのです。

「オブジェクト指向をしない」に向いた Nim のおかげで、相対的に「オブジェクト指向をする」とは何なのかということを、見つめ直せそうな気がします。近年の言語で無意識にコーディングしていて、当たり前だと思っていたことこそが、実はオブジェクト指向であるということだったんじゃないかと。そういえば、意識の負荷を下げてエンドユーザーコンピューティングの時代に進んでいくことが、アラン・ケイの考えたビジョンだったじゃないか、と。

なお、繰り返しになりますが、この記事は、「Simula まんまの成分はオブジェクト指向に含まない」という前提を置いております。この前提をひっくり返すコメントは、ご遠慮のほど、どうかよしなに。

Discussion