秘匿化に向けたJavaScriptの旅

ECMAScriptクラスフィールド(class field)の一覧に、プライベートフィールド(Private field)すなわちプライベートプロパティ(Private Property)(以降Private属性)があります。クラスフィールドのスペックは、Stage 3(Candidate)までアップグレードされたので、おそらくもうすぐStage 4(Finished)を経て標準スペックになるでしょう。初期の仕様文書を見たときは「ついにPrivateができるのか?」という期待感と「文法が少し変わっているな」という失望感だけで、現実味はありませんでした。時の流れとともに記憶が薄れていたところ、TypeScript3.8で正式にサポートされるというニュースを聞き、これを契機にPrivate属性について正しく学ぶことにしました。もちろん、これからはプロジェクトでも積極的に使うつもりです。Publicクラスのフィールドは、Babelプラグインbabel-plugin-proposal-class-propertiesを使って、すでに以前から有効活用しているからです。

プライバシーがない悲しみ

オブジェクトのPrivateな属性を作成できないJavaScriptにおいては、いくつかの代案を使用していました。他のクラス基盤の言語のように、基本的にはPrivateにすることができなかったので、コンベンションとして約束したりしていました。

コンベンションを利用した方法では、慣用的に最も多く使用されるのが「_」すなわちアンダースコア・プリフィクスを属性名に使用するものです。この方法は、Pythonでも使用されています。

function SomeConstructor() {
  this._privateProp = 'dont touch this';
  this.publicProp = 'you can touch this';
}

より旧式に見えるように、サンプルコードではclassキーワードの代わりに関数コンストラクタを使用しました。

この方法は、コンベンションを用いてPrivateとして扱うものの、実際にはPublicで動作するため外部からいくらでもアクセスすることができました。しかし、_がついたフィールドやメソッドを外部で使用してはならないという約束は、コードの可読性を高めるのに一役買ったと思います。まるでfor文に使用するインデックス変数を、何も考えずにiで使用するのと同様に、約束通り一貫性を持って使用すれば、かなり便利な方法であるといえました。ダグラス・クロックフォード(Douglas Crockford)は、ブログを通じて「このような方法は、実際にはPrivateを提供しているのではなく、フィールドがあたかもPrivateで動作していると誤認する恐れがあるため、避けるべき」と述べましたが、開発者間で一貫性のある約束が維持されれば良い方法だったと思います。JSDocが普遍化した後は、文書作成を自動化できる本来の目的のほかにも、コード内のJSDocタグを利用してさまざまな情報や便利な機能を提供するエディタが増えました。JSDocは文書自動作成ツールであると同時に、言語の表現力をコメントで克服した拡張文法になりました。JSDocでは@privateタグを使って当該メンバーがPrivateであることを表現しました。_よりはるかに明示的で文書も自動作成されるとしたら、どれほど使いやすいことでしょう。JavaScript陣営では、ますます_を使用しないようになり、私も同じ理由から_の使用をコンベンションとして禁止することに賛成しました。

根本的にアクセスが不可能なPrivate属性を作成する方法としては、クロージャを用いる方法があります。これはダグラス・クロックフォードが_の代替として使用することを提案した方法でもあります。もしかするとJavascriptにはもっと便利な別の方法があるかもしれませんが、クロージャを使う方法が最も多く用いられています。

function SomeConstructor() {
  const privateProp = 'dont touch this';
  this.publicProp = 'you can touch this';

  this.doSomethingWithPrivateProp = () => { ... }
}

thisを使ってデータにアクセスする文法と形式が異なるため、thisコンテキストと混用する際にはコードの整合性が損なわれ可読性が低下することがありますが、効果的にデータを分離することができました。このようなアクセス方法は、データを非表示にするにも便利ですが、メソッドを隠す場合にも有用です。このような特性を利用して、いわゆるモジュールパターンを実装しました。

function SomeModule() {
  const privateProp = 'dont touch this';
  const publicProp = 'you can touch this';

  _doSomethingWithPrivateProp = () => { ... }

  const publicMethod = () => {
    _doSomethingWithPrivateProp();
    // ...
  }

  return {
    publicProp,
    publicMethod
  }   
}

モジュールパターンは特定の部分(高レベルのインターフェイスやES6のモジュールが使えない状況)では引き続き有用ですが、ES6モジュール(以降ESM)の登場により、私たちのプロジェクトにおいてもコードベースから消えていきました。私は実際にモジュールパターンを使用して開発したときがいつだったか記憶にありませんが、少なくとも5年は経っているでしょう。実際にこのような種類のモジュールパターンがESMの大元でもあり、解決しようとするイシューも同様でした。さらにWebpackを通じて変換されたESMの変換コードを見ると、従来のモジュールパターンと似たような方法が使用されています。Privateのような隠匿性への渇望は、実際にESMを通じてある程度解決されることもありましたが、コンストラクタのインスタンスコンテキストごとにPrivateデータを作成しなければならないという状況は大きく変わった点がありませんでした。

Symbolを使うと、もう少しECMAScriptらしいPrviate属性を作ることができます。

const privateMethodName = Symbol();
const privatePropName = Symbol();

class SomeClass {
  [privatePropName] = 'dont touch this';;
  publicProp = 'you can touch this';

  [privateMethodName]() {
    console.log('private method');
  }

  publicMethod() {
    this[privateMethodName](this[privatePropName]);
  }
}

モジュールスコープの中ではsymbolを使用することができ、対象のフィールドやメソッドにアクセスすることができますが、symbolをエクスポートしない限り、外部からはアクセスする方法がありません。アクセスするフィールドの名前が何か分からないためです。この方法は属性の名前を別の次元に隔離したケースです。

ナイスショップ(#)

そしてついに、JavaScriptでも言語が提供する通常の方法でクラスにPrivate属性を作成できるようになりました。TC39のスペック書をもとに、簡単に特徴を要約すると以下のとおりです。

  • Stage 3のスペックで特別な欠格事由がない限り、標準スペックになるだろう。もちろん変更や改善の余地はある。
  • privateのようなキーワードを使わない。その代わり#ショッププリフィクスを使用する。キーワードではなくプリフィクスを用いて、属性名の前に#がつくPrivateフィールドで動作する。- Class Field Decalarationsスペックの一部。Publicと異なる点は、クラスのフィールド宣言を通じてのみ行うことができる。すなわち動的にオブジェクトにPrivateフィールドを追加することはできない。
  • メソッドには制限的で、メソッドの宣言に使用することができない。Privateメソッドを作るには関数式で定義する必要がある。
  • Computed Property Nameを使用できない。 #fooそのもののみ識別子として許可され、#[fooName]は文法エラーである。
  • すべてのPrivateフィールドは所属するクラスに固有のスコープを持つ。そのため独特の特徴がある。(特徴は後述)

まだ通常のクラス基盤の言語ほどサポートされていません。若干の制約がありますが、まだStage 3であり、いつでも更新または改善される余地があります。しかし、なぜPrivateメソッドが序盤で議論されなかったのかが気になります。

では、実際に使ってみましょう。(エラーメッセージを確認するためにTypeScriptコンパイラを使用しています。しかし、サンプルコードはECMAScript構文のみを使用します。)

class Human {
  #age = 10;
}

const person = new Human();

簡単に#プリフィクスを使ってHumanクラスに#ageという属性を作ってみました。

本当にPrivateになっているかアクセスして確認しましょう。

console.log(person.#age); // Error TS18013: Property '#age' is not accessible outside class 'Human' because it has a private identifier.

クラス外部からアクセスすることができない属性というエラーメッセージが出力されました。
そして、前述のとおり#はキーワードではなく、属性名のプリフィクスです。

class Human {
  #age = 10;

  getAge() {
    return this.age; // Error TS2551: Property 'age' does not exist on type 'Human'. Did you mean '#age'?
  }
}

#なしではアクセスができません。識別子の名前の一部を除いてアクセスしたので、存在しない属性にアクセスしたのです。
それでは正常なPrivate属性をクラスで定義して使ってみましょう。

class Human {
  #age = 10;

  getAge() {
    return this.#age; 
  }
}

const person = new Human();

console.log(person.getAge()); // 10

外部にgetAge()というゲッター(getter)を表示して、#age値にアクセスできるようになりました。

当然のことですが、Privateで作られた属性はそれが定義されたクラスを除いて、どこからもアクセスができません。継承されたクラスからもアクセスできません。試してみましょう。

class Human {
  #age = 10;

  getAge() {
    return this.#age; 
  }
}

class Person extends Human {
  getFakeAge() {
    return this.#age - 3; // Property '#age' is not accessible outside class 'Human' because it has a private identifier.
  }
}

Humanを継承したPersonではHumanのPrivate属性#ageにアクセスすることができません。

しかし、少し独特の特徴があるようです。これはPrivate属性のユニークな特徴ではなく、JavaScriptのため一際ユニークに見える特徴のようです。この特徴は上段にまとめたもののうち「すべてのPrivateフィールドは所属するクラスに固有のスコープを持つ」 という内容によって発生します。

class Human {
  age = 10;

  getAge() {
    return this.age; 
  }
}

class Person extends Human {
  age = 20;

  getFakeAge() {
    return this.age;
  }
}

const p = new Person();
console.log(p.getAge()); // 20
console.log(p.getFakeAge());  // 20

上記サンプルではPrivateを一切使用していません。Humanを継承したPersonオブジェクトからageを重複して宣言し、別の名前のゲッターgetFakeAge()を定義しました。Public属性であれば、thisコンテキストではage属性が1つであるため、ageの値は20です。これはHumangetAge()を実行していたPersongetFakeAge()を実行していたのと同様に20になります。そもそもthisが指すインスタンスコンテキストにageは1つしかないためです。

それでは、ageをPrivate属性#ageに変えてみましょう。

class Human {
  #age = 10;

  getAge() {
    return this.#age; 
  }
}

class Person extends Human {
  #age = 20;

  getFakeAge() {
    return this.#age;
  }
}

const p = new Person();
console.log(p.getAge()); // 10
console.log(p.getFakeAge());  // 20

同じようにthis.#ageにアクセスするgetAge()getFakeAge()の結果が異なります。JavaScriptを長く開発してきた方なら衝撃を受け、混乱することでしょう。これは一体何でしょうか?

#ageすなわちPrivate属性は、これまで私たちが知っていたthisのコンテキストとは別の方法で保存されます。従来のようにインスタンスごとに独立した空間を持ちますが、さらにクラスごとに独立した空間を持つのです。簡単に言えば、Humanクラススコープの#agePersonクラススコープの#ageは違うということです。したがって、Humanクラスに属するgetAge()が実行されるときはHuman#ageにアクセスし、PersongetFakeAge()が実行されるときはPerson#ageにアクセスするのです。それがまさに「すべてのPrivateフィールドは所属するクラスに固有のスコープを持つ」という言葉の意味です。

全体的にPrivateフィールドという概念からは大きく逸れてはいませんが、最後に紹介した内容はきちんと理解せずに使用すると、特定の状況で発見しにくいエラーが作られる恐れがあるので、気をつけましょう。

おわりに

はじめてTC39でスペックを発見してからしばらくの間は、一度も関心を持てないほどにPrivateという概念について無関心でした。長い間、Privateというのもがないオブジェクトを扱う環境で開発してきたせいかもしれませんが、実際のところあまり必要性が感じられませんでした。そのような概念がなかったため、むしろ意識的に識別子を分離して慎重に扱う方法や、クロージャを適切に活用する方法が身についています。そういう見方をすると、より(JavaScript的に)明確な面もあったかもしれません。今後、JavaScript陣営にPrivateという概念がクラスやアプリケーションの設計にどのような影響を与えることになるのか期待されます。

TOAST Meetup 編集部

TOASTの技術ナレッジやお得なイベント情報を発信していきます
pagetop