これは Lost in Translation: Encapsulation – The D Blog を 許可を得て 翻訳した D言語 Advent Calendar 2018 - Qiita 24日目の記事です。
誤訳等あれば気軽に Pull requestを投げてください。
私はプログラミングをBASICで学びました。 そこで成長し、そしてFortranに移りました。 興味深いことに、私の初期のFortranコードはBASICのような見た目でした。 私がCを書き始めたときのコードはFortranのような見た目でした。 私がC++を書き始めたときのコードはCのような見た目でした。 – Walter Bright、Dの創始者
ある言語でプログラミングをすることはその言語で考えることとは異なります。 あるプログラミング言語での経験による自然な副作用として、 我々は他の言語の機能やイディオムを色眼鏡を通して見ることになります。 同じファミリーに属する言語からは同じような印象を受けますが、 そこにはコンパイルエラーやバグ、機会損失につながることのないほど僅かながら確実に違いがあります。 優れたドキュメント、書籍、その他さまざまなものが利用できるにもかかわらず、 多くの誤解は試行錯誤によって解決されてしまいます。
DプログラマはC系の言語をはじめさまざまな言語からやってきています。 それらとの違いと、それをどう使いこなすかを理解することで、 D言語においてコードベースの管理や、APIの設計と実装について多くの可能性が開かれます。 この記事は、似たような言語の経験がもとで見過ごされ誤解されるDの機能について詳説する最初の記事です。
最初に見ていくのはオブジェクト指向プログラミング(OOP)をサポートする言語においては一般的な機能です。 ここには経験豊富なプログラマーが既に完全に理解しているところと、知らなくて驚くところがあります。
カプセル化
読者の多くはカプセル化の概念についてよく知っているでしょうが、ちゃんと読んでほしいところです。 この記事では、カプセル化についてインターフェースの実装からの分離の形で考えます。 これはオブジェクト指向プログラミングと強く結びついたものだ、 というふうに考えがちな人もいるでしょうが、実際はそれよりも幅の広いものです。 こちらのCのコードについて考えてみます。
#include <stdio.h>
static size_t s_count;
void print_message(const char* msg) {
puts(msg);
s_count++;
}
size_t num_prints() { return s_count; }
Cにおいて、関数とstaticのついたグローバル変数はそれが宣言された翻訳単位
(例:#includeで多数のヘッダーと関連しているソースファイル)からプライベートになります。
クライアントの使うパブリックなAPIが置かれるヘッダーファイルに書かれることの多い非静的の宣言は、
パブリックにアクセス可能です。
静的な関数と変数はパブリックAPIから実装の詳細を隠すために使われます。
Cにおけるカプセル化はミニマルなアプローチです。
C++も同じ機能をサポートしていますが、
それは宣言に加えて型定義もカプセル化できる匿名名前空間でもあります。
JavaやC#、その他OOPをサポートする言語のように、
C++にもclassやstructのメンバの宣言に適用できるアクセス修飾子
(access specifier、protection attribute、visibility attributeとも呼ばれます)があります。
C++ には、OOP言語では一般的な以下の3つのアクセス修飾子があります。
public– 世界全体からアクセス可能private– クラスの中でのみアクセス可能protected– クラスとそれを継承したクラスの中からのみアクセス可能
Javaを経験したプログラマーは手を挙げてこう言うかもしれません。
「あー、ちょっといいですか。これはprotectedの完璧な定義ではありません。」
Javaではこのようになっているからです。
protected– クラス、継承したクラス、同じパッケージ内のクラスの中からのみアクセス可能
Javaにおいて各クラスはパッケージに属しているため、 ここにはパッケージが関わっているとしたほうが自然です。 したがってこのようになります。
- package-private(キーワードではありません) – クラス、継承したクラス、同じパッケージ内のクラスの中からのみアクセス可能
これはJavaにおいてアクセス修飾子を指定しなかった場合のデフォルトのアクセスレベルです。
Javaにおいてパッケージをクラスを超えたカプセル化の道具にするprotectedと組合わさります。
同じように、C#にはアセンブリ、MSDNによると「型の集合であり、論理機能単位をつくるリソース」があります。
C#では、protectedの意味はC++のそれと同じですが、
この言語ではそれに加えてアセンブリに関する、
Javaのprotectedやpackage-privateと似ている2つの形態があります。
internal– クラスと同じアセンブリ内のクラス内でのみアクセス可能protected internal– クラス、継承したクラス、同じアセンブリ内のクラス内からのみアクセス可能
他のプログラミング言語のカプセル化について調べることでその類似点と相違点を見つけることができます。 一般的なカプセル化のイディオムは言語特有の機能として存在します。 基本となる概念は同じですが、そのスコープと実装はさまざまです。 なのでDに独自のカプセル化アプローチ、言語特有のやり方があったとしても驚くことではありません。
モジュール
Dのカプセル化のアプローチの基礎となるのはモジュールです。 上記のCコードのDバージョンを考えてみましょう。
module mymod;
private size_t _count;
void printMessage(string msg) {
import std.stdio : writeln;
writeln(msg);
_count++;
}
size_t numPrints() { return _count; }
Dにおいて、アクセス修飾子はclassやstructのメンバのみならず、
モジュールスコープの宣言にも適用できます。
_countはprivate、つまりこのモジュールの外からは見えません。
printMessageとnumPrintsにはアクセス修飾子がついていません。
これらはデフォルトでpublicであり、モジュールの外から見えて、アクセス可能です。
両関数はキーワードpublicで修飾することもできます。
モジュールスコープでのimportはデフォルトでprivateであり、
インポートされたモジュールのシンボルはインポートしたモジュールの外からは見えません。
またこの例で使われているようなローカルインポートは絶対に親スコープから見ることができません。
これ以外の構文もサポートされており、モジュールのレイアウトにフレキシビリティを提供しています。 たとえば、C++スタイル構文もあります。
module mymod;
// これより下にあるすべては他の保護属性が現れるか
// ファイルの終わりまでprivateになります
private:
size_t _count;
// publicに戻します
public:
void printMessage(string msg) {
import std.stdio : writeln;
writeln(msg);
_count++;
}
size_t numPrints() { return _count; }
このようにもできます。
module mymod;
private {
// このブレースのなかで宣言されるものはすべてprivateになります
size_t _count;
}
// この関数はデフォルトのpublicのままです
void printMessage(string msg) {
import std.stdio : writeln;
writeln(msg);
_count++;
}
size_t numPrints() { return _count; }
モジュールはパッケージに属することができます。 パッケージは関連するモジュールをまとめる手段です。 各モジュールに対応するソースファイルは実際にディスク上の同じディパッケージとモジュールについては将来の記事で詳しく取り扱います。レクトリにある必要があります。 ソースファイルのなかで、ディレクトリはモジュール宣言の一部になります。
// mypack/amodule.d
mypack.amodule;
// mypack/subpack/anothermodule.d
mypack.subpack.anothermodule;
ディレクトリと対応しないパッケージ名をつけたり、 ファイルと対応しないモジュール名をつけることも可能ですが、それはバッドプラクティスです。 パッケージとモジュールについては将来の記事で詳しく取り扱います。
モジュール宣言にパッケージが含まれていないため、mymodはパッケージに属しません。
printMessageのなかで、関数writelnはstdioモジュールからインポートされていますが、
stdioモジュールはstdパッケージに属しています。
Dにおいてパッケージは特殊な機能を持っているわけではなく主に名前空間として機能しますが、
but they are a common part of the codescape.
(訳注:ここよく意味がわからなかった。codescapeはなにかのスペルミス?)
モジュールスコープ宣言にはpublicとprivateに加えて、packageアクセス修飾子も適用できます。
同じパッケージの中でのみ見えるようになります。
以下の例について考えてみます。 3つのファイルの中に3つのモジュールがあり(ファイルあたり1モジュールだけが存在できます)、 それぞれ同じrootパッケージに属しています。
// src/rootpack/subpack1/mod2.d
module rootpack.subpack1.mod2;
import std.stdio;
package void sayHello() {
writeln("Hello!");
}
// src/rootpack/subpack1/mod1.d
module rootpack.subpack1.mod1;
import rootpack.subpack1.mod2;
class Speaker {
this() { sayHello(); }
}
// src/rootpack/app.d
module rootpack.app;
import rootpack.subpack1.mod1;
void main() {
auto speaker = new Speaker;
}
これを以下のコマンドでコンパイルします。
cd src
dmd -i rootpack/app.d
-iスイッチはインポートしたモジュールを自動的にコンパイルしてリンクするようコンパイラに指示します
(標準ライブラリの名前空間coreとstdを除く)。
これがない場合、各モジュールはコマンドラインで渡されなければならず、
渡されなかった場合それはコンパイルされないしリンクもされません。
クラスSpeakerは同じパッケージのモジュールに属しているためsayHelloへのアクセス権を持ちます。
リファクタリングの結果sayHelloがrootpackパッケージ全体からアクセスできると便利だ、
と考えた場合を想像してみましょう。
Dはそのための手段としてパッケージの完全修飾名(Fully-qualified name、FQN)
を使いパラメタライズされたpackage属性を提供します。
これを使うとsayHelloの宣言をこのように変更できます。
package(rootpack) void sayHello() {
writeln("Hello!");
}
これでrootpackに属するモジュールと
rootpack以下のパッケージに属するすべてのモジュールはsayHelloへのアクセス権を持ちます。
後者を見落とさないでください。
package属性のパラメータはパッケージとそのすべての子孫がこのシンボルにアクセスできると言っています。
あまりに幅広すぎると思うかもしれませんが、そうではありません。
ひとつには、親パッケージの先祖だけがパラメータに使えるということです。
rootpack.subpack.subsub.mymodというモジュールをもとに考えてみます。
この名前はmymod.d内のpackage属性において合法なすべてのパラメータを含んでいます。
つまり、rootpack、subpack、subsubです。
mymod内で宣言されたシンボルについて以下の属性が付けられます。
package–subsubパッケージのような、mymodの親パッケージ内のモジュールからのみ可視。package(subsub)–subsubパッケージとsubsubのすべての子孫パッケージ内のモジュールから可視。package(subpack)–subpackパッケージとsubpackのすべての子孫パッケージ内のモジュールから可視。package(rootpack)–rootpackパッケージとrootpackのすべての子孫パッケージ内のモジュールから可視。
この機能によりパッケージはカプセル化のツールになり、外の世界からシンボルを隠し、 しかしパッケージヒエラルキーの特定のサブツリーからのみ可視かつアクセス可能にできるようになります。 実際には、アクセス権をサブツリー全体にひろげるのが望ましいというケースは多くありません。
たとえばgraphicsパッケージと、
DirextX、Metal、OpenGL、Vulkanなどのための実装を含むサブパッケージのように、
パブリックなインターフェースを公開し、
なおかつ1つ以上のサブパッケージ内にある実装を隠したいというシチュエーションでは、
パラメタライズされたパッケージ保護は一般的に見られます。
Dのアクセス修飾子は3段階のカプセル化を可能にします。
- 全体は
graphicsパッケージ - 各サブパッケージは実装を含む
- 各パッケージは独立したモジュールになっている
私は4段階目としてclassやstructを入れていません。
次のセクションで理由を説明します。
クラスと構造体
この記事で書きたかったところにやってきました。 どれほどの人が Dフォーラムに来て パッケージ保護について驚きを表明したかもはや覚えていませんが、 クラスと構造体の中のアクセス修飾子の振る舞いは、主に他の言語での経験から生じる期待のために、 唐突に飛び出してきたように感じるものです。
クラスと構造体はモジュールのそれと同じアクセス修飾子を使います。
public、package、package(some.pack)、privateです。
構造体が継承をサポートしていないため(モジュールもオブジェクトではないため)、
protected属性はクラスでのみ使えます。
public、package、package(some.pack)はモジュールレベルのそれと同様に振る舞います。
驚く人がいるのはprivateを同じように使ったときです。
import std.stdio;
class C {
private int x;
}
void main() {
C c = new C();
c.x = 10;
writeln(c.x);
}
Dについて調べる人によってこのようなコード片が、
「なぜこれがコンパイルされるのですか?」
(ときに「バグを見つけたかもしれません!」)という質問とともに度々投稿されます。
これは経験が予想を誤らせる例です。
privateの意味はみんな知っているので、
多くの人が言語仕様のドキュメントを読んで悩むようなものではないはずです。
しかし、ドキュメントを読んだ人はこのような記述を見つけます。
privateのついたシンボルは同じモジュール内からのみアクセスできます。
Dにおけるprivateは常にモジュールのprivateを意味しています。
モジュールはカプセル化の最低段階です。
これがカプセル化を破っていると言われる理由を理解することもできますが、
これはカプセル化を強化するためのものです。
これはC++のfriend機能からインスパイアされたものです。
C++コンパイラを長年実装、メンテナンスしてきたことにより、
Walterはfriendのような機能の必要性を理解しましたが、
これが目的を達成する最善の方法ではないとも思いました。
どこか別のファイルで走る"friend"を宣言できるようにすることはカプセル化と逆行しています。
他の方法としてモジュールあたり1つのクラスというJava風のアプローチがありますが、 彼はこれを厳しすぎると思いました。
概念をカプセル化する相互に強く接続されたクラスが必要とされていて、それはモジュールに入るべきです。
Dにとってモジュールは単なる1つのソースファイルではなく、カプセル化の1ユニットです。
関数、クラス、構造体がその中に含まれ、
そのすべてがモジュールスコープとクラススコープで宣言された同じデータを操作できます。
しかしやはり、パブリックなインターフェースはモジュール内のプライベートな実装から保護されています。
同様に、protectedなクラスメンバは継承先クラスからアクセス可能なだけでなく、
モジュール内からもアクセス可能です。
そうはいっても、モジュール内からのプライベートなメンバのアクセスを禁止する利点も存在します。
大きなモジュールは、特にそれをチームでメンテしている場合、メンテする負担が大きくなってきます。
どのクラスのprivateメンバもモジュール内からアクセスされるということは、
変化を起こしうる場所が増えるということであり、そのためメンテナンスの負担を増やします。
この言語は特殊なパッケージモジュール
の形で負担を軽減する手段を提供しています。
時に、ユーザーに複数のモジュールを別々にインポートする手間をかけさせたくない、 という状況があります。 大きなモジュールを小さく分割した時などはその一例です。 以下のようなファイルツリーについて考えてみます。
-- mypack
---- mod1.d
---- mod2.d
mypackと呼ばれるパッケージに2つのモジュールがあります。
mod1.dが非常に大きく成長してしまいメンテナンスできるか心配になってきたとします。
プライベートなメンバが、クラスの宣言の外、何百行、
何千行とあるなかから操作されていないことを確実にしておきたいです。
モジュールをもっと小さくしたいですが、
それと同時にユーザーコードを破壊するようなこともしたくありません。
現在、ユーザーはモジュールのシンボルをimport mypack.mod1とインポートできるようになっています。
これが動作し続けるようにしたいのです。
以下が現在の状況です。
-- mypack
---- mod1
------ package.d
------ split1.d
------ split2.d
---- mod2.d
mod1.dを新しい2つのモジュールに分割し、mod1という名前のパッケージに配置しました。
ここで以下のようなpackage.dという特殊なファイルも作っています。
module mypack.mod1;
public import mypack.mod1.split1,
mypack.mod1.split2;
コンパイラがpackage.dを見つけると、それは特別に扱われます。
ユーザーはモジュールが新しいパッケージ内の2つのモジュールに分割されたことを気にすること無く
import mypack.mod1を使い続けられます。
カギとなるのはpackage.dの上の部分のモジュール宣言です。
この宣言は、このパッケージをモジュールmod1として扱うようコンパイラに指示します。
そしてモジュール内のすべてのパッケージは自動的にインポートされず、
かわりにpackage.d内のパブリックなインポートとしてリストする必要があります。
これによりパッケージの実装がより自由になります。
package.dがありながら、ユーザーにはモジュールの明示的なインポートを要求したいときもあるでしょう。
ユーザーはmod1を単一のモジュールとして扱い続けることもできるし、
そのようにインポートし続けることもできます。
同時に、内部のカプセル化はより強力に行われるようになりました。
split1とsplit2は分離したモジュールのため、他方のプライベートな部分に触ることはできません。
両モジュールで共有されてほしいAPIの各部分はpackageで修飾できます。
内部的な変化にかかわらず、パブリックなインターフェースは変化せず、カプセル化は保たれています。
要約
以下がDで宣言できるアクセス修飾子の完全なリストです。
public– どこからでもアクセス可能。package– 同じパッケージのモジュールからアクセス可能。package(some.pack)– パッケージsome.packとその子孫パッケージ内のモジュールからアクセス可能。private– モジュール内でのみアクセス可能。protected(クラスでのみ有効) – モジュールと継承先クラスからアクセス可能。
この記事があなたに「母国語」ではなくDで考える視点を、Dのカプセル化について与えられたなら幸いです。
この記事のレビューとフィードバックの提供をしてくれた Ali Çehreli、 Joakim Noah、 Nicholas Wilson に感謝します。