JavaScript で extends 節に式が来れる話の深掘り
先日次のようなツイートをしました。
いくつかご指摘のあった通り、正確には 500ms ごとに新しいクラスを定義しているだけなので 1 秒おきに親クラスが変わっているわけではないのですが、あくまでも構文として extends
節に式が生起できることに着目していました。
さて、このような構文が書ける JavaScript ですが、構文から利用例まで、様々な観点からこの仕様について眺めてみたので、それをまとめていきます。
背景
例えば、JavaScript では次のようなコードを書くことができます。
class X {
hello() {
console.log("X");
}
}
class Y {
hello() {
console.log("Y");
}
}
class A extends [X, Y][new Date().getTime() % 2] {
func() {
this.hello();
}
}
new A().func();
このコードを実行すると、実行するタイミングによって X
または Y
が表示されます。これはクラス A
の親クラスが実行時間によって決定されているためで、この決定はクラス A
の extends
節の直後に生起している式によって行われています。
このコードを見て、extends
節に式が来れることに驚いた方は多いのではないでしょうか。自分も Java などの言語から来た人間なので、ここには Identifier、即ちクラス名などの名前を表す識別子しか生起できないという先入観がありました。
JavaScript は動的型付け言語であることから百歩譲ってこのような仕様を飲み込めるとして、静的型付け言語である TypeScript を書いてみると、この違和感はさらに大きくなると思います。TypeScript は JavaScript に型注釈[1]をつけた言語であるという事実を踏まえたとしても、これが静的型付け言語であるというならば、実行時に親クラスが変化しうるコードが書けるということはなかなかに受け入れ難いものです。
そして自分の知っている限り、多くの静的型付け言語では extends
節の後には識別子しか許されていません。例として、いくつかの言語の構文定義を眺めてみます。
様々な言語の継承周りの構文
ここでは、静的型付け言語である Java と C#、そして動的型付け言語である Ruby と Python の構文定義を見てみます。
Java
Java23 の Java SE Language Specification の Chapter 8. Classes に、次のような定義があります。
ClassExtends: extends ClassType
ClassType:
{Annotation} TypeIdentifier [TypeArguments]
PackageName . {Annotation} TypeIdentifier [TypeArguments]
ClassOrInterfaceType . {Annotation} TypeIdentifier [TypeArguments]
いくつか記述がありますが、いずれも識別子のようです。
C#
Classes - C# language specification | Microsoft Learn において、クラスの継承に関わる構文については次のように定義されています。
class_base
: ':' class_type
| ':' interface_type_list
| ':' class_type ',' interface_type_list
;
// https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/language-specification/types#821-general
class_type
: type_name
| 'object'
| 'string'
;
やはり識別子のみです。
Ruby
クラス/メソッドの定義 (Ruby 3.3 リファレンスマニュアル) のクラス定義の節に次のような定義があります。
class 識別子 [`<' superclass ]
式..
end
継承を表すトークン <
の次に superclass
が生起しますが、これは識別子のようです。しかし、参考までに クラス/メソッドの定義 (Ruby 3.3 リファレンスマニュアル) の特異クラス定義の節に次のような定義があります。
class `<<' expr
式..
[rescue [error_type,..] [=> evar] [then]
式..]..
[else
式..]
[ensure
式..]
end
クラスの継承とは異なりますが、クラス構文と同じ構文で特定のオブジェクトにメソッドやインスタンス変数を定義/追加できるような構文があります。Kotlin の拡張関数に似た雰囲気を感じます。
Python
8. 複合文 (compound statement) — Python 3.6.15 ドキュメントに次のような構文定義があります。
classdef ::= [decorators] "class" classname [inheritance] ":" suite
inheritance ::= "(" [argument_list] ")"
classname ::= identifier
// https://docs.python.org/ja/3.6/reference/expressions.html#calls
argument_list ::= positional_arguments ["," starred_and_keywords]
["," keywords_arguments]
| starred_and_keywords ["," keywords_arguments]
| keywords_arguments
positional_arguments ::= ["*"] expression ("," ["*"] expression)*
starred_and_keywords ::= ("*" expression | keyword_item)
("," "*" expression | "," keyword_item)*
keywords_arguments ::= (keyword_item | "**" expression)
("," keyword_item | "," "**" expression)*
keyword_item ::= identifier "=" expression
inheritance
の argument_list
に式が記述できるようです。とりわけ 3.3.3.1 メタクラス によれば metaclass
にメタクラスとしてクラスが指定できるようですが、ここはクラス名(識別子)のみのようです。
構文の考察
ECMAScript と MDN での記述
まずは ECMAScript の仕様を見てみます。15.7 Class Definitions の節には次のような構文定義があります(表現の都合上一部を抜粋・改変しています)。
ClassDeclaration :
class BindingIdentifier ClassTail
class ClassTail
ClassExpression : class BindingIdentifier(opt) ClassTail
ClassTail : ClassHeritage(opt) { ClassBody(opt) }
ClassHeritage : extends LeftHandSideExpression
ここで一番最後の行の ClassHeritage
の左辺にある非終端文字 LeftHandSideExpression
に着目してみます。13.3 Left-Hand-Side Expressions において、この非終端文字は次のように定義されています。
LeftHandSideExpression :
NewExpression
CallExpression
OptionalExpression
~~Expression
という名称からも推測できるように、この非終端文字は式を表しています(名前から推測するのは記事のためで、全ての構文を追うと式であることは明らかだと思います)。
ここまでの調査で、extends
節の後に式が生起することは ECMAScript において定められた仕様であることが確認できました。
続いて MDN での記述を見てみましょう。extends - JavaScript | MDN には、次のような記述があります。
extends の右辺は識別子である必要はありません。コンストラクターとして評価される式なら何でも使用することができます。これはミックスインを作成するのに有益なことが多いです。
MDN においても extends
の後ろには識別子が来る必要はないと明示されており、加えてミックスインの作成に有益であるとまで記述されています。しかし、ここに気になる記述:「コンストラクターとして評価される式なら何でも使用できる」があります。これは一体どういうことでしょうか。ここを糸口にして、なぜ extends
節に式が許される(あるいは、来なければいけない)のかを考察していくことにします。
「コンストラクターとして評価される式」とは
考察の前に、JavaScript におけるクラスとはどのようなものかを明らかにしておきます。クラス構文は ES2015 で導入され、それ以前は関数を記述することによってクラスを実現[2]していました。無論構文は先に示した通りで、一部を除いては Java のような構文で記述します。
一方で仕組みについてはどうでしょうか。クラス - JavaScript | MDN に次のような記述があります。
JS のクラスはプロトタイプに基づいて構築されています...クラスは実際には「特別な関数」 であり...
これはどういうことでしょうか。MDN を読めば明らかですが、せっかくの記事なので何かをここに書く必要があります。とはいえ MDN2 を作っても仕方がないので、先の記事の内容の要点を示すことにします。
- クラスはコンストラクターやフィールド、メソッドなどを持つ
- クラスの評価時にコンストラクターが抽出され、なければ規定の実装(Object.prototype.constructor)で置き換えられる
ここで着目すべき点として、クラスの評価プロセスに明確にコンストラクターが絡んでいるということがあります。逆にいえば、コンストラクターの存在が、あるオブジェクトがクラスであることの必要条件であると解釈することもできます。なおクラスの評価プロセスについては評価の順序の節にて解説されています。
そしてクラスをインスタンス化するには new
演算子の適用が必要です。new 演算子 - JavaScript | MDN によれば、new
演算子の適用にはコンストラクターの実行が伴います。従って new
演算子が適用できる式こそが、コンストラクターとして評価される式であると結論づけることができます。そしてクラス以外にも関数をコンストラクター関数とみなすことで new
演算子を適用することができるため、「コンストラクターとして評価される式」の最終的な評価結果はクラスでも関数でも良いことになります。
ここまで長々と解説しましたが、結局のところ、このご指摘が全てだと思います(ありがとうございます)。
extends 節に式が生起できる理由の結論
ここまでで、「コンストラクターとして評価される式」が何たるかを明らかにしました。そして JavaScript では、即時関数式やクラス式など様々な形で関数やクラスを定義することができます。重要なのは、式で関数やクラスが定義できる以上、あらゆるところで「コンストラクターとして評価される式」が発生するという点です。このような特性を踏まえると、JavaScript においてクラスや関数に名前がついているのは、それらの特殊な場合とみなすことができます。
そしてそうであるならば、extends
節に来れる要素を Identifier に縛る必要はもう無くなってしまいます(むしろ縛ってしまうと、継承ができないクラス(-like-object)が大量に発生してしまう)。だから JavaScript では extends
節に式が生起できるのです。
活用例
ここまでで extends
節に式が生起できる理由がわかりました。この章では、実際にこの仕様がどのように活用されているのかを見てみます。
vue-class-component (Vue2) の Mixin
今となってはもう過去のものですが、Vue2 用の vue-class-component の Mixins でこの仕様が使われています。例えば Extend and Mixins | Vue Class Component では次のような例が示されています。
アプリケーションコード
import Component, { mixins } from 'vue-class-component'
import { Hello, World } from './mixins'
// Use `mixins` helper function instead of `Vue`.
// `mixins` can receive any number of arguments.
@Component
export class HelloWorld extends mixins(Hello, World) {
created () {
console.log(this.hello + ' ' + this.world + '!') // -> Hello World!
}
}
まさに extends
節で mixins
関数を呼び出しています。mixins
はクラスから Mixins を生成する関数のようです。一応 TypeScript の型定義も確認しておきます。次のコードは GitHub からの引用です。
mixins
export function mixins <A> (CtorA: VueClass<A>): VueClass<A>
export function mixins <A, B> (CtorA: VueClass<A>, CtorB: VueClass<B>): VueClass<A & B>
export function mixins <A, B, C> (CtorA: VueClass<A>, CtorB: VueClass<B>, CtorC: VueClass<C>): VueClass<A & B & C>
// ...
export function mixins<T>(...Ctors: VueClass<Vue>[]): VueClass<T>
VueClass
export type VueClass<V> = { new (...args: any[]): V & Vue } & typeof Vue
TypeScript の交差型と new ()
をうまく使って表現されていますが、黒魔術みを感じます。
まとめ
本記事では、JavaScript の継承元(extends
節)に式が生起できる理由を考察しました。実は仕事で Vue を書いていた時に気がついた構文だったのですが、深掘りしてみると興味深い点が多くありました。特に構文に JavaScript のセマンティクスが現れていることが如実に現れていた点はかなり面白かったです。
Discussion