V8 javascript engineについての細かい話 (Node.js Advent Calendar 2017)
Node.js Advent Calendar 2017 25日目の記事です。トリとなります。
さて先日11/26・27日に行われたNode学園祭でv8について発表させて頂いたが、
30分という制約上色々カットせざるを得なかった。
またv8のコードを読む・コントリビュートする上で伝えられる事も色々と溜まったので一度アウトプットすることにした。
というわけでまとまりのない記事になる可能性が高いがご容赦いただけると助かります。
事前資料
以下のスライドがNode学園祭の発表資料なので読んどいていただけると理解がはやいかも
前準備
チェックアウト
v8はGitHubに直接はホスティングされていない。
GitHub上にあるv8リポジトリはミラーで実際にはchromium.googlesource.comにホスティングされている。
ただし開発の際にはGitHubのリポジトリをフォークしてgit remote add fork git@github.com:<user-name>/v8.git
とかしてfork
リモートを追加してやるとよい。
あとは実際にpushして保存したい場合にはこのGitHubのフォークに対して行う。
なぜこんな面倒な事をしているかというと、v8のリポジトリはGitHubではないためgit push
を使わない。
git cl
というコマンドを利用するのだが、このコマンドでgit cl upload
としてもGerritにレビューを送るだけでpush
相当のことはできない。
その為GitHubをブランチの保存先に利用している。
さて、v8を実際に開発するためには最初にこのページを確認すると良い。
Contributing · v8/v8 Wiki · GitHub
ただしあんまり親切ではないので軽く説明すると、まず最初に以下のステップに従ってdepot_toolsをインストールする
depot_tools_tutorial(7)
インストールしてパスを通したら、gclient
コマンドが叩けるか確認する。
その後コードを以下の要領でチェックアウトする。
Using Git · v8/v8 Wiki · GitHub
あとはgit checkout -b foo-branch
で適当なブランチを作って作業を開始する。
ビルド
一旦チェックアウトできたらビルドをしてみる。
現在v8はGNというメタプロジェクトビルドツールを利用しており、以下のpythonコマンドでビルド設定を出力できる。
X64の例
DEBUG
./tools/dev/v8gen.py x64.debug -vv
OPT_DEBUG
./tools/dev/v8gen.py x64.optdebug -vv
RELEASE
./tools/dev/v8gen.py x64.release -vv
次にビルドを行うのだが、ビルドにはNinjaというビルドツールを利用しており、以下のコマンドでビルドが開始できる。
DEBUG
ninja -C out.gn/x64.debug
OPT_DEBUG
ninja -C out.gn/x64.optdebug
RELEASE
ninja -C out.gn/x64.release
後はビルド完了を待つだけ。
ちなみにフルビルドには2.5 GHz Intel Core i7
16GB Memory
で30分近くかかるので辛抱強く待つ。
コミット
作業が完了したらいつも通りgit add .
して、git commit
でコミットコメントを書く。
コードをコミットする場合にはgit cl format
で最初に全ファイルをフォーマットし、git cl upload
でコードをGerritにコミットする。
この際chromium.org
メールアドレスを持っていないとWarningが表示されるもののここはYを押して先に進む。
その後コミットメッセージの入力を求められるが、gitのコミットメッセージを利用する場合にはそのままEnterでオッケー。
レビュー
もしレビューを開始する場合にはgit cl owners
コマンドでレビュアーを探し出して、GerritのReviewsにそのレビュアーを追加して待つ。
エディタ・IDE
自分はEmacs + RTagsを利用している。
この辺の記事が参考になった。C++11時代のEmacs C++コーディング環境 - Qiita
またv8のコミッターに直接聞いたところ、CLionを使ってると言っていた。CLion: A Cross-Platform IDE for C and C++ by JetBrains
ただし、自分の環境ではしょっちゅうフリーズしていたので諦めた。
あとはvimが多いみたいだが、vimはよくわからんので頑張ってC++環境作ってください。
VSCodeは無理だった。
Atomってなんだっけ?
v8のコード構成
ディレクトリ構成概要
v8のソースコードは全てsrc
ディレクトリに格納されており、以下のような構成になっている。
src ---+ | +---arm +---arm64 +---mips +---mips64 A +---ia32 +---x64 +---ppc +---s390 +---wasm +---asmjs | +---ast +---compiler B +---compiler-dispatcher +---interpreter +---parsing | +---js +---builtins C +---runtime +---snapshot +---regexp +---profiler | D +---ic | +---heap E +---heap-symbols.h +---zone +---objects | F +---inspector | +---base +---debug +---tracing +---extensions G +---libplatform +---libsampler +---third_party +---trap-handler | +---*.cc/*.h . . .
ファイル数が非常に多くすべてを説明し切るのは非常に難しいので一旦概略を述べると、
- Aグループ
- ディレクトリ群はassemblerコードやdisassembler、macro-assembler、simulator等、CPU毎の個別コードが格納されている。
- Bグループ
- Cグループ
- JSのビルトイン関数・v8内部の実行時ヘルパ関数等が格納されている。
- Dグループ
- Inline Cache周りのコードが格納されている。
- E
- オブジェクトモデルとメモリ周りのコードが格納されている。
- F
- インスペクタ
- G
- デバッグやプラットフォーム抽象化層のコードを格納している。
とこんな感じで分類できる。
ただしこれも結構苦しい分類で実際にはどこのディレクトリにも属していないsrc
直下のファイルが各グループに相当するコードを実装していたりと、結構見境がない感じ。
主要ファイル
恐らくよく見ることになるソースを列挙しておく。
- api.h/api.cc
- Embedder向けのAPIが定義されている。
- objects.h/objects.cc
- v8のオブジェクトモデルすべてが定義されており
objects.cc
に関しては19366行ある。
- v8のオブジェクトモデルすべてが定義されており
- compiler/compiler.cc
- コンパイルのエントリポイントになるのでここからコードを追うことが多い。
- compiler/pipeline.cc
- compiler.ccからつながってここにたどり着く。TurboFanと呼ばれている箇所
- runtime/runtime-*.cc
- ランタイム関数が定義されている。ここもよく見る。
- builtins/builtin-*.cc
- より高速なランタイム関数群。CodeStubAssembler(あとで解説)かAssemblerで記述されている。
- interpreter/*.cc
- いわゆるIgnitionのコードがここに収まっている。
- ic/*.cc
- Inline Caching周りの実装・ランタイムが格納されている。
v8の内部実装
ここからは実際に内部で使われている主要な機能を紹介していく。
公開API
v8::HandleScope
v8のGCから割り当てられたオブジェクトをC++の世界でも監視するための仮想スコープを生成する。
下のv8::Local
で詳しく解説する。
v8::Local
おそらく最もよく見ることになるクラス。
v8はGCを持っているが、C++にはGCは無い。
代わりにRAII(Resource Acquisition Is Initialization)と呼ばれるリソースの確保・破棄をメモリと紐付けて行う方法が一般的である。
C++にはデストラクタと呼ばれる、スタックに割り当てられたクラスがスコープを抜けて破棄される際に必ず呼ばれる関数があり、
そのクラスでポインタをラップすることでスコープを抜けた時に一緒にポインタを破棄するという使い方をよくする。いわゆるスマートポインタと呼ばれる機能である。
v8::Local
はヒープに割り当てたオブジェクトをC++の世界で監視するためのラッパクラスで、デストラクタが呼ばれれたタイミングで現在のHandleScope
と共に削除される。
例.
void test() { v8::Isolate* isolate = v8::Isolate::GetCurrent(); v8::HandleScope handle_scope; v8::Local<v8::Array> array = v8::Array::New(isolate, 3); ... }
一度v8::HandleScope
が生成されるとv8::Local
はすべてそのv8::HandleScope
に割り当てられる。
そのため、test関数が終了したタイミングでhandle_scope
のデストラクタが呼び出されると、そのv8::HandleScope
に紐づくすべてのv8::Local
も同時に削除される。
v8::Handle
v8::Local
でラップされているが、実際にv8::HandleScope
に紐付いているクラス。
apiによってはこっちのv8::Handle
を返すやつもあるが、まあ基本的にはv8::Local
と同じように使えば良い。
v8::Isolate
最初に説明すべきか悩んだが、一旦v8::Local
を先に回した。
v8::Isolate
はv8のコードベースを貫く基礎になる部分でかなり特殊な作りになっている。
もともとv8はstatic
メソッドが非常に多く、マルチスレッドについてあまり考えていない作りになっていた。
まあ、これはChromium側でプロセス分離してv8を起動すればよかったので問題はなかったのだが。
さて、いざEmbedder側でマルチスレッド化しようとするとかなり問題を引き起こすことがわかった。
そのため、このv8::Isolate
という仕組みを結構無理やり組み込んだ。
v8::Isolate
がどんなものかというと、Thread Local Storage(Tls)に格納された巨大なオブジェクトになっており、
実行コンテキストに紐づくグローバルな情報をほぼ全て格納している。
Tlsに格納されているのでスレッド毎に違うv8::Isolate
を透過的に持つことが可能でEmbedder側はスレッドをあまり意識せずにコードを書くことが可能になっている。
内部で利用する様々なオブジェクト(FixedArray
)やHidden Classを表すMap
クラス等もこのv8::Isolate
から生成している。
このクラスはほぼすべての箇所に渡されていて、v8::Isolate
無しではコードを書くのは難しい状態になっている。
上記のサンプルコードを再度使うと
void test() { v8::Isolate* isolate = v8::Isolate::GetCurrent(); v8::HandleScope handle_scope; v8::Local<v8::Array> array = v8::Array::New(isolate, 3); ... }
この関数でもv8::Isolate
を渡しているのがわかる。
v8::Array::New
などとしているが実際にArray
を生成しているのはv8::Isoalte
である。
そのため、v8の内部ではあまりスレッドの競合について考える必要が無く、まあなかなか便利な仕組みである。
v8::internal
外部公開API以外のクラス等はすべてv8::internal
名前空間に定義されている。
長いのでv8::i
に省略する。
オブジェクトモデル
v8は非常に特殊な作りになっており、C++の中に独自のオブジェクトモデルを作り出している。
そのオブジェクトモデルはsrc/objects.h
の冒頭コメントに記載されており、
それを簡略化して抽出するとこうなる。
- Object
- Smi (immediate small integer)
- HeapObject (superclass for everything allocated in the heap)
- JSReceiver (suitable for property access)
- JSObject
- JSProxy
- FixedArrayBase
- ByteArray
- BytecodeArray
- FixedArray
- FixedDoubleArray
- Name
- String
- Symbol
- HeapNumber
- BigInt
- Cell
- PropertyCell
- PropertyArray
- Code
- AbstractCode, a wrapper around Code or BytecodeArray
- Map
- Oddball
- Foreign
- SmallOrderedHashTable
- SharedFunctionInfo
- Struct
- WeakCell
- FeedbackVector
- JSReceiver (suitable for property access)
v8::i::Object
を基底クラスとしたオブジェクトツリーを作り出しており、
v8内部で使用されるほぼすべてのクラスはv8::i::Object
を継承している。javaみたいだね。
こういうわかりやすいヒエラルキーがあるのでさぞ読みやすいかと思いきや、コードは非常に複雑で...まあ読みやすくはない。
v8はこのオブジェクトモデルをうまく機能させるためにC++のやり方に従わない。
どういうことかと言うとこれらのクラスはフィールドをC++のクラスを通じて実装していない。
これらのクラスはあくまでメモリレイアウトをC++の世界で表現するためだけに存在しており、すべてのフィールドはthis
ポインタに対して直接オフセット指定して取得している。
つまりC++のオブジェクトレイアウトを無視して、自分たちで完全にメモリレイアウトをコントロールしている。
擬似コードで表現すると以下のようになる。
class SomeObject { Value* get_field1() { char* self = reinterpret_cast<char*>(this); self += header_offset; return Value::Cast(self); } void Initialize() { char* self = reinterpret_cast<char*>(this); self += header_offset; *self = Smi::Cast(1); } }; static const size_t OBJECT_SIZE = sizeof(char) * 32; SomeObject* object = reinterpret_cast<SomeObject*>(malloc(OBJECT_SIZE)); object->Initialize(); object->get_filed1(); // 1
この様に自分でフィールドのオフセットを制御している。
さて、このオブジェクト階層の頂点からチェックしていこう。
まずv8::i::Object
は以下の2つに分岐する。
- Smi
- 31ビット整数、ポインタアドレスの末尾は常に0
- ヒープに確保されることはない
- HeapObject
- 4バイトアライメントの32ビットポインタ。アドレス末尾は1
- ヒープに確保されたオブジェクト。GCの対象になる。
HeapObject
まずはv8::i::HeapObject
から。
v8::i::Object
は上述のように直接メモリレイアウトをいじっているので
HeapObjectを継承したオブジェクトはフィールドにアクセスする場合には以下のようなマクロを使っている。
#define FIELD_ADDR(p, offset) \ (reinterpret_cast<byte*>(p) + offset - kHeapObjectTag) #define READ_FIELD(p, offset) \ (*reinterpret_cast<Object* const*>(FIELD_ADDR_CONST(p, offset))) // こちらはGCのConcurrentマーキングがONになっている場合にアトミックにフィールドを更新するために // AtomicWordを使ったバージョンと通常のものに分岐している。 #ifdef v8_CONCURRENT_MARKING #define WRITE_FIELD(p, offset, value) \ base::Relaxed_Store( \ reinterpret_cast<base::AtomicWord*>(FIELD_ADDR(p, offset)), \ reinterpret_cast<base::AtomicWord>(value)); #else #define WRITE_FIELD(p, offset, value) \ (*reinterpret_cast<Object**>(FIELD_ADDR(p, offset)) = value) #endif SMI_ACCESSORS(FixedArrayBase, length, kLengthOffset) #define SMI_ACCESSORS_CHECKED(holder, name, offset, condition) \ int holder::name() const { \ DCHECK(condition); \ Object* value = READ_FIELD(this, offset); \ return Smi::ToInt(value); \ } \ void holder::set_##name(int value) { \ DCHECK(condition); \ WRITE_FIELD(this, offset, Smi::FromInt(value)); \ } // 実際には以下のように展開される。 int FixedArrayBase::length() const { DCHECK(condition); Object* value = (*reinterpret_cast<Object* const*>( reinterpret_cast<const byte*>(this) + kLengthOffset - kHeapObjectTag) return Smi::ToInt(value); } int FixedArrayBase::set_length(int value) const { DCHECK(condition); base::Relaxed_Store( reinterpret_cast<base::AtomicWord*>( reinterpret_cast<byte*>(this) + kLengthOffset - kHeapObjectTag); reinterpret_cast<base::AtomicWord>(Smi::FromInt(value))); }
重要なのは、
reinterpret_cast<const byte*>(this) + kLengthOffset - kHeapObjectTag
の部分で、this
ポインタに特定のフィールドのオフセットを足したあと、kHeapObjectTag
を引いているのがわかる。
ちなみにkHeapObjectTag
の定義は以下。
const int kHeapObjectTag = 1
ただの1、つまりポインタアドレス末尾に1を立てるだけ。
v8::i::HeapObject
は割り当て時にkHeapObjectTag
分多めに確保してから割り当てる。
以下がサンプルコード
#include <stdio.h> #include <stdlib.h> #include <iostream> const int kHeapObjectTag = 1; const int kHeapObjectTagSize = 2; const intptr_t kHeapObjectTagMask = (1 << kHeapObjectTagSize) - 1; inline static bool HasHeapObjectTag(const char* value) { return ((reinterpret_cast<intptr_t>(value) & kHeapObjectTagMask) == kHeapObjectTag); } int main() { auto allocated = reinterpret_cast<char*>( malloc(sizeof(char) * (2 + kHeapObjectTag))); auto heap_object = allocated + kHeapObjectTag; heap_object[0] = 'm'; heap_object[1] = 'v'; printf("%ld %ld %p %p %d\n", reinterpret_cast<intptr_t>(allocated), reinterpret_cast<intptr_t>(heap_object), allocated, heap_object, HasHeapObjectTag(heap_object)); free(allocated); }
実行すると私の環境では以下の結果になる。
140289524108464 140289524108465 0x7f97b3400cb0 0x7f97b3400cb1 1
見事にアドレスの末尾1が立っている。
またv8::i::HeapObject
は自分自身の型を識別するためにHidden Classを表すv8::Map
オブジェクトを先頭に持っている。
そのためv8::i::HeapObject
もメモリレイアウトは以下のようになる。
+-----+-----------------------+--------+ | Map | Derived Object Header | values | +-----+-----------------------+--------+
必ず先頭に型を表すv8::Map
を持っているため、そこを見ればv8::i::HeapObject
の型がわかる。
またDerived Object Header
と書かれている部分は継承先のオブジェクトによって異なる(v8::i::FixedArray
ならlengthフィールドだったり)。
以下がMapとJSObjectを簡略化した表したC++コード
#include <stdio.h> #include <stdlib.h> #include <iostream> const int kHeapObjectTag = 1; const int kHeapObjectTagSize = 2; const intptr_t kHeapObjectTagMask = (1 << kHeapObjectTagSize) - 1; inline static bool HasHeapObjectTag(const char* value) { return ((reinterpret_cast<intptr_t>(value) & kHeapObjectTagMask) == kHeapObjectTag); } class Map { public: enum InstanceType { JS_OBJECT, JS_ARRAY, JS_STRING }; void set_instance_type(InstanceType instance_type) { instance_type_ = instance_type; } InstanceType instance_type() { return instance_type_; } private: InstanceType instance_type_; }; const int kHeaderSize = sizeof(Map); typedef char byte; typedef char* Address; class HeapObject { public: char value() {return reinterpret_cast<Address>(this)[0];} Map::InstanceType instance_type() { return reinterpret_cast<Map*>( reinterpret_cast<Address>(this) - kHeaderSize)->instance_type(); } void Free() { auto top = reinterpret_cast<Address>(this) - kHeaderSize - kHeapObjectTag; free(top); } protected: static Address NewType(Map::InstanceType instance_type, size_t size) { auto allocated = reinterpret_cast<Address>( malloc(sizeof(byte) * (size + kHeaderSize + kHeapObjectTag))); auto map = reinterpret_cast<Map*>(allocated); map->set_instance_type(instance_type); return allocated + kHeaderSize + kHeapObjectTag; } }; class JSObject: public HeapObject { public: static JSObject* New() { auto a = NewType(Map::JS_OBJECT, 1); a[0] = 'o'; return reinterpret_cast<JSObject*>(a); } }; class JSArray: public JSObject { public: static JSArray* New() { auto a = NewType(Map::JS_ARRAY, 1); a[0] = 'a'; return reinterpret_cast<JSArray*>(a); } }; class JSString: public JSObject { public: static JSString* New() { auto a = NewType(Map::JS_STRING, 1); a[0] = 's'; return reinterpret_cast<JSString*>(a); } }; int main() { JSObject* objects[] = { JSObject::New(), JSArray::New(), JSString::New() }; for (int i = 0; i < 3; i++) { auto o = objects[i]; switch (o->instance_type()) { case Map::JS_OBJECT: printf("JSObject => %c\n", o->value()); break; case Map::JS_ARRAY: printf("JSArray => %c\n", o->value()); break; case Map::JS_STRING: printf("JSString => %c\n", o->value()); break; } } for (int i = 0; i < 3; i++) { objects[i]->Free(); } }
実行するとJSObject => o, JSArray => a, JSString => s
が出力される。
かなり例が巨大になってしまったが、これでヒープに割り当てられたオブジェクトの型を正確に分類できているのがわかると思う。
さらにSmiが何かを説明しよう。
Smi
SmiはSmall Integerの略で、31ビットまでの整数を直接ポインタ領域に確保する。
またRubyでも同じ手法が取られているようだ。
通常ポインタはそれだけで32ビットCPUなら4バイト、64CPUなら8バイトを使う。
つまり31ビットまでの整数ならば直接ポインタの代わりに格納できるわけだ。
このようにポインタ領域に確保してヒープを使わないことでメモリ節約・高速化の両方を成し遂げている。
さてどのように格納するかというと直接int
値をreinterpret_cast<T*>
してポインタに変換してしまう。
class Smi { public: static Smi* FromInt(int value) { return reinterpret_cast<Smi*>(value); } int value() { return reinterpret_cast<intptr_t>(this); } }; Smi* NewSmi(int value) { return Smi::FromInt(value); } int main() { printf("%d %d\n", NewSmi(120)->value(), NewSmi(110)->value()); // out 120 110 }
上記の例を見ていただければわかるだろう。
さらにv8::i::HeapObject
の場合には下位1ビットを立てたが、Smiの場合には末尾0をタグとして利用しているので、
キャストしただけで直接数値演算が可能である。そのためオーバーヘッドもない。
また64ビットCPUならばポインタは64ビットなのでより大きな整数が格納できるが、32ビットとの互換性のため31ビットの領域しか使用しない。
64ビットの場合のビットレイアウト
+----------------+-----------------+------------------+ | 31 bit integer | 32 bit zero bit | 1 bit smi tag(0) | +----------------+-----------------+------------------+
単純に下位32ビットをゼロ埋めするだけ。
JSReceiver
プロパティアクセス可能なJSオブジェクトを表す。つまりほぼすべてのJSオブジェクトを表す。
JSReceiver
の下には上述のJSObject
が存在し、これがjsのObject
クラスを表す。
さらにJSObject
の下には以下のような階層がある。
- JSArray
- JSArrayBuffer
- JSArrayBufferView
- JSTypedArray
- JSDataView
- JSBoundFunction
- JSCollection
- JSSet
- JSMap
- JSStringIterator
- JSSetIterator
- JSMapIterator
- JSWeakCollection
- JSWeakMap
- JSWeakSet
- JSRegExp
- JSFunction
- JSGeneratorObject
- JSGlobalObject
- JSGlobalProxy
- JSValue
- JSDate
- JSMessageObject
- JSModuleNamespace
- WasmInstanceObject
- WasmMemoryObject
- WasmModuleObject
- WasmTableObject
これらのv8::i::JS~
クラスは我々EmbedderがAPI経由で利用するv8::String
やv8::Array
等のクラスの本当の姿で、
v8::String
等のクラスはただのラッパークラスでしかない。
実際の実装はすべてv8::i::JS~
クラスが持っている。
FixedArrayBase
v8で頻出するクラスであるv8::i::FixedArray
のベースとなる実装。
v8は内部のいたるところでこの固定長配列を利用しており、何度もお目にかかることになる。
v8::i::FixedArray
は更に以下の様な階層を持っている。
- DescriptorArray
- FrameArray
- HashTable
- Dictionary
- StringTable
- StringSet
- CompilationCacheTable
- MapCache
- OrderedHashTable
- OrderedHashSet
- OrderedHashMap
- Context
- FeedbackMetadata
- TemplateList
- TransitionArray
- ScopeInfo
- ModuleInfo
- ScriptContextTable
- WeakFixedArray
- WasmSharedModuleData
- WasmCompiledModule
特にv8::i::DescriptorArray
はプロパティディスクリプタを格納している配列で、
プロパティアクセスの際によく取得する。
オブジェクトに関しては1記事では限界があるので次に進む。
次はv8内部のコード生成部分に関して
CodeGeneration of v8
v8はいくつかのコード生成方法がある。
v8-devグループでスライドがあったのでそれを参考にまとめると、
- C++
- C++(External Reference)
- CodeStubAssembler(CSA)
- Javascript
- pros
- JSから呼び出すのが高速
- cons
- 意図せぬ型情報の汚染が起こりうる。パフォーマンス問題も起きがち。
- セキュア(getter関数を意図せず呼び出したり、monky-patchingされてしまったり)に作るのが非常に難しい
- Compiler組み込み関数やruntime関数呼び出しが必要で、これが高コスト
- summary
- 使わないこと!
- pros
- アセンブリ
- pros
- とにかく早い
- コールスタックを操作できる
- cons
- メンテナンスコストが高すぎる
- アーキテクチャ毎に必要で大変
- summary
- 使わないこと!
- pros
という感じでコードを書く手段がC++, C++(Ex)、CSA、javascript、アセンブリの約5種類ある。
がjavascript、アセンブリはすでに新規で書かれることはほぼない。
以前はv8はjavascriptでランタイムを書いていた時期があったがほぼなくなった。
現在は低速でも問題ない箇所や新たに実装されるSpecに関してはC++で、それ以外のパフォーマンスが必要な箇所に関してはCSAで記述される。
さて次はCSAを解説する。
CodeStubAssembler (CSA)
v8の内部で利用されるDSL言語。
実はv8ではすでに新しくアセンブリ言語を記述することはあまりない。
その代わりアセンブリを出力するCSAを記述することでよりメンテナンス性が高く高速なコードを出力することができる。
以下にCSAの例を示す。
function fibonacci(num){ var a = 1, b = 0, temp; const result = []; while (num >= 0){ result.push(a); temp = a; a = a + b; b = temp; num--; } return result; }
C言語入門 - フィボナッチ数の計算 - サンプルプログラム - Webkaru
このフィボナッチ数を計算して配列に格納するjavascript関数をCSAに変換すると以下のようなコードになる。
TNode<JSArray> Fibonacci(TNode<Context> context) { TVARIABLE(var_a, MachineType::PointerRepresentation(), IntPtrConstant(0)); TVARIABLE(var_b, MachineType::PointerRepresentation(), IntPtrConstant(1)); TVARIABLE(var_temp, MachineType::PointerRepresentation()); TVARIABLE(var_index, MachineType::PointerRepresentation()); Node* fixed_array = AllocateFixedArray(PACKED_ELEMENTS, IntPtrConstant(11), INTPTR_PARAMETERS, kAllowLargeObjectAllocation) Label loop(this), after_loop(this); Branch(IntPtrGreaterThan(IntPtrConstant(100), var_index), &loop, &after_loop); BIND(&loop); { StoreFixedArrayElement(fixed_array, SmiTag(var_index), var_a, SKIP_WRITE_BARRIER); var_temp.Bind(var_a); var_a.Bind(IntPtrAdd(var_a, var_b)); var_b.Bind(var_temp); Increment(&var_index, 1); Branch(IntPtrGreaterThan(IntPtrConstant(100), var_index), &loop, &after_loop); } BIND(&after_loop); Node* native_context = LoadNativeContext(context); Node* array_map = LoadJSArrayElementsMap(PACKED_ELEMENTS, native_context); Node* array = AllocateUninitializedJSArrayWithoutElements( array_map, SmiConstant(12), nullptr); StoreObjectField(array, JSArray::kElementsOffset, fixed_array); return array; }
ある程度抽象化されているとは言えどうしても冗長なコードになってしまうが、アセンブリよりは遥かに読みやすいのではないだろうか?
これらは直接値を出力するのではなく、実行予定ツリーを組み立ててからツリーを巡回し、一つ一つの命令が自動的にアセンブリに置き換えられる。
ちなみにコンパイルしていないのでもしかしたらコンパイルエラーが出るかもしれない。
長くなりすぎたので内部の解説はこのあたりで終了する。
コードを読む
v8のコードを読むのは非常に面倒だが、いくつか方法がある。
まずはそれぞれのエディタのコードジャンプを使う。
まあこれでとりあえず定義とかはある程度見れるはず。
ただしv8は大量のマクロを使っており、クラスの関数定義すらマクロで行う場合があるので、どうしても定義が見つからない場合にはfind | grep
も駆使したほうが良い。
またマクロで文字列結合されている場合もあるので、find src | grep '##FooBar'
みたいな感じで##
を使ってgrep
する必要があるかもしれない。
デバッグする
コードを読んでも実行時の状態がわからなかったり呼び出し階層がわからない場合もあるので、ランタイムでは以下の方法でチェックすると良い。
- C++
src/base/debug/stack_trace.h
にStackTrace
クラスがあるので呼び出された箇所でStackTrace st;st.Print()
を呼ぶと良い。- また
v8::Object
クラスを継承しているオブジェクトはもれなくPrint
メソッドを持っているので、a->Print()
を呼ぶと中身が見れる。
- CSA
Print()
関数がCodeStubAssembler
に定義されているので、そこにNode*
を渡すことでa->Print()
を行うコードを出力してくれる。ただし、IntPtrT
を渡すと落ちるので注意。その場合はSmiTag
すれば良い。
あとはこれを繰り返すしかない。
まとめ
なんか本当にまとまりのない文章になってしまった。申し訳ない。
まあつまりv8のコード見るのも書くのも大変だってことです。
mallocを再実装した話
C++ AdventCalendarの12日目
普段私はWEBのフロントエンドを仕事にしている。
つまり使う言語はjavascript/typescript等のScript言語だ。
ただ前職や趣味、OSS等でC++によく触っていたので昔実装したmallocの話をすることにした。
mallocとは
mallocとはC言語のstdlib.hに含まれるメモリ割り当て関数のことで、
C++やその他の多くの言語で内部的に利用されている。
ヒープを割り当てる方法はいくつかあるが、このmallocがもっともメジャーといえるだろう。
mallocを再実装した
今回はmallocを自分で再実装してちょっと早くした話を書く。
再実装した理由は色々あるが最も大きな理由はただの好奇心。
yatsc
というtypescript
のコンパイラをC++で書こうと思って実装を始めたときに作った。
ただしyatsc
自体は未完で飽きて終了。
作ってみて思ったが、実際にプロダクトで使う場合はシンプルなメモリプールとか作ったほうがよっぽど利口だと思う。
Inside malloc
malloc
関数は現在のモダンな環境ではmmapを利用してメモリを確保、それを各チャンクに分けて管理しているはず。
細かいことは以下のスライドに詳しい。
www.slideshare.net
Direction of implementations
今回はGoogleのTLMallocの実装と上記のスライドをベースにjust-fitアロケータを作成することにした。
基本的な構成は以下の通り。
メインスレッドにあるCentralArena
が各スレッドのLocalArena
を管理しており、
それぞれのArenaはTls(Thread Local Storage)に格納されている。
また、各LocalArena
はスレッドが終了すると、free-listに格納されて空き状態となり、
新たにスレッドが生成された場合に再利用される。
LocalArena自体もlinked-listになっており、next
ポインタで各スレッドのLocalArenaはつながっている。
さらにLocalArena
は内部にChunkを持ち、それらはChunkHeader
と後続の複数のHeapHeader
によって管理されている。
Tlsにヒープを格納することでスレッド毎にヒープ領域を分割し、スレッド競合を防ぎつつjust-fitアロケータでメモリの無駄も防ぐ。
というのが、このmalloc実装のキーポイントになる。
ちなみにTlsを実装するのは面倒だったので、AndroidのTls実装をコピーした。
Tlsのコールバックまで実装されており非常に便利。サンキューGoogle
以下はCentralArena
からLocalArena
までの図
+----------------+ +---------------+ | CentralArena |--| Main Thread | +----------------+ +---------------+ | | +------------+ +--------------+ | +--| Thread-1 |--| LocalArena | | | +------------+ +--------------+ | | +------------+ +--------------+ | +--| Thread-2 |--| LocalArena | | | +------------+ +--------------+ | | +------------+ +--------------+ | +--| Thread-3 |--| LocalArena | | +------------+ +--------------+ +------------+ +--------------+ +--------------+ | free list |--| LocalArena |--| LocalArena |-- ... +------------+ +--------------+ +--------------+
CentralArena
CentralArenaはメモリのアロケーションを要求されると、
GetLocalArena
関数を呼び出して、TlsからLocalArenaを取り出すか空いているLocalArenaをfree-listから探し出して割り当てる。
以下のような感じでAtomicにlockを行ってスレッド競合を防いでいる。
LocalArena* CentralArena::FindUnlockedArena() YATSC_NOEXCEPT { // If released_arena_count_ is zero, // that mean free arena is not exists. // So we simply allocate new LocalArena. if (released_arena_count_.load() == 0) { return StoreNewLocalArena(); } LocalArena* current = local_arena_.load(std::memory_order_relaxed); while (current != nullptr) { // Released arena is found. if (current->AcquireLock()) { return current; } current = current->next(); } // If released arena is not found, // allocate new LocalArena. return StoreNewLocalArena(); }
ちなみにAcquireLockは以下のような感じ。Atomicにロックしている。
YATSC_INLINE bool AcquireLock() YATSC_NOEXCEPT { bool ret = !lock_.test_and_set(); if (ret) { central_arena_->NotifyLockAcquired(); } return ret; }
さて上記のFindUnlockedArena
を使って空いたLocalArenaを探し出すのに失敗した場合、
そこで初めてメモリを割り当てることになる。
このメモリ割り当ては以下のような感じで行う。
LocalArena* CentralArena::StoreNewLocalArena() YATSC_NOEXCEPT { Byte* block = reinterpret_cast<Byte*>( GetInternalHeap(sizeof(LocalArena) + sizeof(InternalHeap))); LocalArena* arena = new (block) LocalArena( this, new(block + sizeof(LocalArena)) InternalHeap( block + sizeof(LocalArena) + sizeof(InternalHeap))); arena->AcquireLock(); LocalArena* arena_head = local_arena_.load(std::memory_order_acquire); // Run CAS operation. // This case ABA problem is not the matter, // because the local_arena_ allowed only one-way operation // that is append new LocalArena. // So head of local_arena_ is change only when new local arena is appended. do { arena->set_next(arena_head); } while (!local_arena_.compare_exchange_weak(arena_head, arena)); return arena; } // Get heap space that used to allocate LocalArena and ChunkHeader. YATSC_INLINE void* CentralArena::GetInternalHeap(size_t additional) { return VirtualHeapAllocator::Map( nullptr, sizeof(ChunkHeader) * kChunkHeaderAllocatableCount + additional, VirtualHeapAllocator::Prot::WRITE | VirtualHeapAllocator::Prot::READ, VirtualHeapAllocator::Flags::ANONYMOUS | VirtualHeapAllocator::Flags::PRIVATE); }
解説すると、まず最初にVirtualHeapAllocator
を利用してメモリを割り当てる。
このVirtualHeapAllocator
はmmap
のプラットフォーム毎の差分を吸収したクラス。
このVirtualHeapAllocator
からLocalArena
とInternalHeap
という内部向けの管理ヘッダ分のメモリを割り当てる。
それをinplacement newを利用してLocalArenaに割り当てる。
つまりLocalArenaは以下のようなメモリレイアウトになる。
sizeof(InternalHeap) + sizeof(LocalArena) +-------------------------------+ +--------------+ | InternalHeap(hidden header) |---| LocalArena | +-------------------------------+ +--------------+
あとは割り当てたLocalArena
のロックを獲得して、local-arenaのリストに格納する。
このリストに追加するのも当然atomicに行わなければならないので、CAS(Compare And Swap)を利用してリストに追加する。
CASを使うとABA問題を気にしなければならないが、コメントにある通りlocal_arena_のリストは追加しか行わないのでABA問題は気にしなくて良い。
これでめでたくCentralArenaからロックフリーでLocalArenaを確保することに成功した。
次はLocalArenaを実装する。
LocalArena
LocalArena
こそがメモリ割り当てのコアになっていて、この部分が一番面倒くさい。
LocalArena
はメモリ割り当て要求を受けるとsmall_bin_
とよばれる小さなメモリ専用のツリーにメモリを割り当てていく。
今回はこのツリーはRedBlackTreeを利用した。
このsmall_bin_
に要求メモリサイズ毎のリストを作りそこに割り当てていく。
+--------------+ | LocalArena | +--------------+ | | +--------------+ +--------| small_bin_ | +--------------+ | +-----------------------+ | | +-------+ +-------+ | 1byte | | 2byte | +-------+ +-------+ | | +-----------+ +-----------+ | | | | +-------+ +-------+ +-------+ +-------+ | 3byte | | 4byte | | 5byte | | 6byte | +-------+ +-------+ +-------+ +-------+
こんな感じでsmall_bin_には各サイズ毎のchunkのリストが格納される。
これでjust-fitなアロケータになる。
ただし面倒だったのは、このsmall_bin_
をstd::mapとかで実装するのはちょっと難しく、
結局自分でRBTreeを実装する羽目になった。
何故かと言うと、今はメモリアロケーションの途中なのでヒープから値を確保することは不可能で、
各Chunkに追加のメモリ割り当てで格納できるコンテナが必要だったからである。
自分で実装したRBTreeはIntrusiveRBTree
というクラスで、ある条件を満たせばヒープからメモリのアロケーションをしなくてもツリーを構成することができる。
その条件とは格納される値自身がコンテナの実装をしていること。
つまり格納される値がRbTreeNode
というRedBlackTreeのNode
を継承していれば、
RBTree自身はそれをつなぎ合わせるアルゴリズムの実装のみでよく、
メモリ割り当てを必要としないで済む。
ただし、small_bin_
は名の通り小さなメモリのみを格納する場所なので、64KB
を制限とし、
それを超える場合には直接CentralArena
から巨大なメモリ領域をmmap
で割り当てる。
そのときにはSpinLock
でロックをかけるので当然遅くなる。
これでjust-fitなbinが実装できた。
さてsmall_bin_
に格納されるメモリを実装していく。
small_bin_
に格納されるメモリはChunkHeader
という管理ヘッダと実際に値を割り当てる後続のブロックで構成される。
ChunkHeader
ChunkHeader
クラスは実際に値を割り当てるヒープを管理している。
ChunkHeader
の後ろには各Heapを管理するHeapHeaderがつながっている。
確保されているヒープはheap_list_
に格納され、
空いているヒープはfree_list_
に格納される。
+---------------+ | ChunkHeader | +---------------+ | | | +--------------+ +------------+ +------------+ +------------+ | | heap_list_ |--| HeapHeader |--| HeapHeader |--| HeapHeader | | +--------------+ +------------+ +------------+ +------------+ | | +--------------+ +------------+ +------------+ +------------+ | free_list_ |--| HeapHeader |--| HeapHeader |--| HeapHeader | +--------------+ +------------+ +------------+ +------------+
そしてChunkHeaderはメモリ確保の要求が来ると、最初にfree_list_
を探索する。
もしfree_list_
にHeapHeaderがあればそれをheap_list_
にAtomicに繋いで返す。
空きがなければ、最後につながれたHeapHeader
の使用量を確認して、まだ格納できるのであればHeapHeader
に値を追加する。
もしHeapHeader
に空きが無ければ、新たにHeapHeader
を割り当てる。
YATSC_INLINE void* ChunkHeader::Distribute() { auto free_list_head = free_list_.load(std::memory_order_acquire); if (free_list_head == nullptr) { // If heap is not filled. if (heap_list_->used() < max_allocatable_size_) { // Calc next positon. Byte* block = reinterpret_cast<Byte*>(heap_list_) + heap_list_->used(); // Update heap used value. heap_list_->set_used(heap_list_->used() + size_class_); return block; } else { // If heap is exhausted allocate new memory. Byte* block = InitHeap(AllocateBlock()); auto heap_header = reinterpret_cast<HeapHeader*>( block - sizeof(HeapHeader)); heap_header->set_used(heap_header->used() + size_class_); return block; } } // Swap free list. while (!free_list_.compare_exchange_weak( free_list_head, free_list_head->next())) {} return reinterpret_cast<void*>(free_list_head); }
HeapHeader
HeapHeader
クラスは実際にヒープの値を直接管理しているクラス。
このHeapHeader
はChunkHeader
から割り当てられる、1 MBでアライメントされたメモリブロックとなっている。
なぜ1 MBでアライメントするかというと、メモリの節約のためにHeapHeader
が確保しているメモリブロックは管理ヘッダを持っていない。
本来はメモリを削除する場合にHeapHeader
を参照するための情報が必要なのだが、1 MBでアライメントすることによって、
メモリの下位16ビットをマスクするだけで、HeapHeader
の先頭アドレスを取得することができるようになる。
こうすることで1割当ごとにX64なら最低でも8 byte、x86なら4 byteの節約になる。
CentralArena
にメモリのfree
要求が来た場合には以下のようにポインタのアドレス下位16ビットをマスクして、
HeapHeader
を取得する。
※ ChunkHeader::kAddrMask
は~0xFFFF
auto h = reinterpret_cast<HeapHeader*>( reinterpret_cast<uintptr_t>(ptr) & ChunkHeader::kAddrMask); ASSERT(true, h != nullptr); ChunkHeader* chunk_header = h->chunk_header(); ASSERT(true, chunk_header != nullptr); chunk_header->Dealloc(ptr);
構造は以下の図の通り
+----------------------------+ +----------------------+ +-----------------------+ | HeapHeader(0x103e600000) |--| value(0x103e600008) |--| value(0x103e000010) | +----------------------------+ +----------------------+ +-----------------------+
削除する場合
+----------------------+ +----------------------------+ | value(0x103e600008) | => 0x103e600008 => | HeapHeader(0x103e600000) | +----------------------+ ^^^^^ Mask heare. +----------------------------+
概ねこんな感じでjust-fitでロックを必要としないメモリアロケータを実装することができた。
パフォーマンスだが、シングルスレッドではmalloc
の約5倍ほど高速化することができたが、
マルチスレッドでは1.2
~ 1.1
倍ほどしか高速化せずちょっとなぁ〜という感じ。
また64 KB以上のビックな割当を行うとほとんどmallocと変わらないのでここは改善の余地がありそう。
という感じでmallocを再実装した話でした。
全体像は以下のようになる。
+----------------+ +---------------+ | CentralArena |--| Main Thread | +----------------+ +---------------+ | | +------------+ +--------------+ | +--| Thread-1 |--| LocalArena | | | +------------+ +--------------+ | | | | | | +--------------+ | | ---------| small_bin_ | | | +--------------+ | | | | | ------------+------------ | | | | | | +-------+ +-------+ | | | 1byte | | 2byte | | | +-------+ +-------+ | | | | | | ------+------ ------+------ | | | | | | | | +-------+ +-------+ +-------+ +-------+ | | | 3byte | | 4byte | | 5byte | | 6byte | | | +-------+ +-------+ +-------+ +-------+ | | +---------------+ | | | ChunkHeader | | | +---------------+ | | | | | | | +--------------+ +------------+ +------------+ +------------+ | | | | heap_list_ |--| HeapHeader |--| HeapHeader |--| HeapHeader | | | | +--------------+ +------------+ +------------+ +------------+ | | | +----------------------------+ +----------------------+ +-----------------------+ | | | | HeapHeader(0x103e600000) |--| value(0x103e600008) |--| value(0x103e000010) | | | | +----------------------------+ +----------------------+ +-----------------------+ | | | | | +--------------+ +------------+ +------------+ +------------+ | | | free_list_ |--| HeapHeader |--| HeapHeader |--| HeapHeader | | | +--------------+ +------------+ +------------+ +------------+ | | +------------+ +--------------+ | +--| Thread-2 |--| LocalArena | | | +------------+ +--------------+ | | +------------+ +--------------+ | +--| Thread-3 |--| LocalArena | | +------------+ +--------------+ +------------+ +--------------+ +--------------+ | free list |--| LocalArena |--| LocalArena |-- ... +------------+ +--------------+ +--------------+
まとめ
結果得られたのはロックフリーな実装の難しさとメモリ割当関連のバグは発見が不可能に近いという教訓であった。
意味不明なSegvがきつい。
参考
https://www.slideshare.net/kosaki55tea/glibc-malloc
ソースは https://github.com/brn/yatsc/tree/master/src/memory どす〜
TypeScript 2.6 変更点と注意点
TypeScript2.6が出たので変更点を記載
RCからほぼ変更点がない。
Strict Function Typeフラグの導入
--strictFunctionTypes
というフラグが導入される。
このフラグは--strict
フラグに内包されており、--strict
の場合は自動でONになるが、
--strictFunctionTypes
をfalse
にすることで個別にOFFにすることもできる。
動作
関数の引数に対するVarianceの動作を変更する。
TypeScriptの関数のVarianceについては以前下のスライドで説明したので参照。
今回の--strictFunctionTypes
フラグがONになると、関数がBivariantではなくてContravariantになる。
つまり以下のような代入が許可されなくなる。
class Animal { ... } class Dog extends Animal { ... } declare let acceptAnimal: (animal: Animal) => void; declare let acceptDog: (dog: Dog) => void; acceptAnimal = acceptDog // Error acceptDog = acceptAnimal // OK!
またこの変換が不可能になると、以下のように総称型の代入規則が変化する。
class Animal { ... } class Dog extends Animal { ... } class GenericAnimal<T> { someFn(animal: T) {} } delcare let animalAnimal: GenericAnimal<Animal>; delcare let animalDog: GenericAnimal<Dog>; animalAnimal = animalDog // Error. animalDog = animalAnimal // OK. // someFn(animal: Dog) // someFn(animal: Animal)
これは関数がBivariantではなくなるために起こる。
Animal
を受け取る関数にDog
を受け取る関数を渡すと、Dog
がAnimal
の派生型である限り、
Dog
固有の処理を行っていた場合にRuntimeエラーを起こす可能性が高く危険な代入になるが、
Animal
を受け取る関数にDog
を渡すのは安全であるため、このような変換規則になる。
そのため外から見るとDog <= Animal
の変換をしているように見えるが、関数の引数という文脈で考えると、
この変換はAnimal <= Dog
の変換となり自然に見える。
注意
--strict
モードでこの機能が有効になるため、--strict
オプションをONにしているコードに対しては、
この変換規則が強制的に適用される。
なので今まで逆の変換を行っているコードはすべてコンパイルエラーになる。
それが困る場合は--strictFunctionTypes false
でOFFにすることができる。
タグ付きテンプレートリテラルをキャッシュするようになった
タイトル通り。
一度作ったテンプレートリテラルオブジェクトをキャッシュするようになったので、以下のコードがtrue
になるようになった。
export function id(x: TemplateStringsArray) { return x; } export function templateObjectFactory() { return id`hello world`; } let result = templateObjectFactory() === templateObjectFactory(); // true in TS 2.6
Emit Result in v2.5
"use strict"; exports.__esModule = true; function id(x) { return x; } exports.id = id; function templateObjectFactory() { return (_a = ["hello world"], _a.raw = ["hello world"], id(_a)); var _a; } exports.templateObjectFactory = templateObjectFactory; var result = templateObjectFactory() === templateObjectFactory(); // true in TS 2.6
Emit Result in v2.6
"use strict"; var __makeTemplateObject = (this && this.__makeTemplateObject) || function (cooked, raw) { if (Object.defineProperty) { Object.defineProperty(cooked, "raw", { value: raw }); } else { cooked.raw = raw; } return cooked; }; function id(x) { return x; } var _a; function templateObjectFactory() { return id(_a || (_a = __makeTemplateObject(["hello world"], ["hello world"]))); } var result = templateObjectFactory() === templateObjectFactory();
ここに注目
// v2.5 function templateObjectFactory() { return (_a = ["hello world"], _a.raw = ["hello world"], id(_a)); var _a; } // v2.6 var _a; function templateObjectFactory() { return id(_a || (_a = __makeTemplateObject(["hello world"], ["hello world"]))); }
変数_a
に生成済みテンプレートオブジェクトをバインドして再利用しているのがわかる。
コンパイルエラーメッセージ・コマンドラインヘルプが多言語化
localeに合わせて各種言語ファイルが使用されるようになった。
// @ts-ignoreコメントでjsファイルのエラーを抑制できるようになった。
--allowJS
オプションを使ってJSファイルをロードした際に出るコンパイルエラーを、
if (false) { // @ts-ignore: Unreachable code error console.log("hello"); }
のような形で指定することで抑制できる。
--watchモードが高速化
--watch
機能をすべて書き直して高速化した。
プロジェクト全体をコンパイルするのではなく、変化したファイルのみを処理することで処理速度が向上した。
WriteOnlyな値をunusedとして処理するように
TypeScript2.6では、--noUnusedLocals
と--noUnusedParameters
オプションを刷新した。
値が宣言されているか書き込まれていても、その値が読み出されない限りunusedとしてマークされる。
function test(unused1: number) { unused1 = 0; } class Test { private unused2: number; constructor() { this.unused2 = 0; } }
上記のサンプルコードではunused1
とunused2
はともに書き込みはあるものの、
読み出しがないのでunusedとしてエラーになるので注意。
追記
LanguageService系の更新書くの忘れてた
implicit anyのQuick fixを追加
implicit anyが見つかった場合にnoImplicitAnyがtrueならQuickFixで推測した型を提案する。
JSDocをTypeScriptの型アノテーションに変換する
JSDocに書いた型注釈をTypeScriptの型に変換できるようになった。
呼び出しが必要なDecoratorにWarningを出すようになった
高階関数型の呼び出しが必要なDecoratorを呼び出し無しで使った場合に、
呼び出しを提案する。
@typesから自動でinstall
型定義がない場合に@typesから自動でインストールを提案する。
Ambient宣言内のdefault exportで式が使えなくなった
タイトルの通り
Intersection Typeの結果を変更
number & string, "foo" & 42,
のような不可能な型はnever型を返すように
lib.d.ts変更
Deprecation
getSymbolDisplayBuilder
APIが廃止になった。2.7で削除予定
基本的には代わりにTypeChecker#symbolToString
を使う。
もっと入り組んだユースケースの場合にはバージョン2.7でsymbolToString
を導入予定なのでそれを使う。
まとめ
今回はちょっと破壊的変更がいくつかあるので注意。
特に--strict
をONにしている人は総称型のアップキャストをしていないかをチェックした方がいい。
Ecmascriptのprotocolについて
Ecmascriptにprotocolを実装するという提案がある。
もともとGotanda.jsで発表した内容だけどいろいろ追記した。
資料はこれ
内容
元々はinterfaceの提案だった。
それが名前と形を変えてprotocolの提案になった。
protocolとは?
classが実装すべき規約。
interfaceと違って型ではなく規約を提供する。
型クラスとかが近いのかな?
Ecmascript protocol
基本
今回の提案はちょっと特殊な形のprotocolの実装で、
実態はSymbolのコレクションになっている。
例.
protocol ProtocolName { // 実装が必要なシンボルを宣言 thisMustBeImplemented; } class ClassName implements ProtocolName { [ProtocolName.thisMustBeImplemented]() { ... } }
上の実装を見るとわかるけど、protocolのメンバーは自動的にSymbolになる。
そしてprotocolを実装するクラスはそのSymbolを実装しなければいけない。
さらにprotocolは複数実装できる。
例.
protocol A { a; } protocol B { b; } class ClassName implements A, B { [A.a]() {} [B.b]() {} }
またprotocolは実装を持つこともできる。
例.
protocol ProtocolName { // 実装が必要なシンボルを宣言 thisMustBeImplemented; // メソッド実装 youGetThisMethodForFree() { return this[ProtocolName.thisMustBeImplemented](); } } class ClassName implements ProtocolName { [ProtocolName.thisMustBeImplemented]() { ... } } const className = new ClassName(); className.youGetThisMethodForFree(); // ClassNameのインスタンスで呼べる
このprotocolは実装を持っていて、それを実装したクラスは同時に実装も引き継ぐ。
なのでtraitの様に振る舞うことができる。
拡張
さらに既存のクラスも拡張することができる。
たとえばArrayを拡張する場合。
protocol Functor { map; } Promise.prototype[Functor.map] = () => { ... } // このimplement関数でFunctorを実装したことを宣言 Protocol.implement(Promise, Functor);
このように既存クラスのprototypeを拡張し、implement関数を呼び出すことで、
既存のクラスにもprotocolを適用することができる。
チェック
あるクラスがprotocolを実装しているかどうかは、instanceof
では判定できない。
なので、implements
演算子が提案されている。
Promise implements Functor // true if (MyClass implements SomeProtocol) { }
上記の例のようにimplements
キーワードが演算子のように振る舞う。
extends
protocol自体は既存のprotocolを拡張することができる。
例.
protocol A { a; } protocol B { b; } protocol C extends A, B { c; }
こんな感じでprotocolは複数のprotocolを拡張することができる。
デフォルト実装
上記の拡張を利用するとデフォルト実装を持つprotocolを定義することができる。
例.
protocol A { a; } protocol B extends A { [A.a]() { ... } } class Class implements B {} const c = new Class(); c[A.a]();
protocol Bはprotocol Aを拡張し更にaを実装している。
なので、Class
のインスタンスに対してA.a
を呼び出すことが可能になる。
static protocol
protocolはなんと静的プロパティ(!)に対しても作用させられる。
例.
protocol A { static b() {} } class C implements A { } C[A.b]();
これはどうなんだ...
TypeScript
とりあえず既存のinterfaceは残すであろうとのこと。
protocolをどのような型として扱うかは未定。
Allow dynamic name in typesという型のプロパティ名にsymbolを使うことができるPRが進んでいるので、
これが実装されるとprotocolもスムーズにいけるかも。
ちなみにここで議論しています。
prototypeへの直接代入について
先程、既存のクラスにprotocolを実装するケースでprototypeへ直接代入するという例を出したが、
prototypeへの直接代入は何か気持ち悪い...(Object.definePropertyすらない時代からjsを書いていたせいなのか?)ので
それはやめてくれ!その代わり既存のクラスをopen-classにして拡張できるようにすれば良くない?
というアバウトな提案をしてみた。
protocol Functor { map; } // We can implements protocol after declaration. Array implements Functor { [Functor.map]: Array.prototype.map }
こんな感じ。
結果
とりあえず、classがprototypeを隠蔽したのにこれじゃES5時代じゃないか!
って思いを伝えたら。
classはprototypeを隠蔽したわけじゃないよ。prototypeへの代入もES5時代的ではない。
Ecmascriptのモンキーパッチ方法は常にprototypeだし、これからもjsは未来永劫変わらずこの方法なのさ。
とこのような調子で言われた。
まあEcmascriptがprototypeを隠蔽して、もっと 普通 のclassベース言語になりたがってるように見えていた私にとって、
これが目指す世界ならもう言うことは無かったので議論を打ち切った。
でもprototypeはもう触りたくないんじゃ!
おしまい。
HTML5 Conference 2017に登壇します
ひょんなことから
に登壇する機会をいただけることに。
タイトルはDeep dive into TypeScriptということで、TypeScriptの紹介や、RoadMap・Issuesの話とかをさせて頂くつもり
同じ時間帯に任天堂の方のセッションとかあってアレなんですが、TypeScriptに興味のある方はぜひ。
Typescript 2.5 リリース
Typescript 2.5がでたので変更点をメモ
Optional-catch-bindingの導入
catch節のエラーオブジェクトが省略可能になった。
現在Stage3のOptional catch bindingが導入された形に。
サンプル
let input = "..."; try { JSON.parse(input); } catch { // ^ catch節の変数宣言を省略している。 console.log("Invalid JSON given\n\n" + input) }
jsにJSDocベースの型アサーションとキャストを導入
javascriptファイルもJSDocコメントで型チェック可能になった。
コメントで@ts-checkをつけるか、
コンパイラオプションでcheckJsオプションをtrueにすれば動作する。
型チェックの詳細は以下のWikiに書いてある。
Type Checking JavaScript Files · Microsoft/TypeScript Wiki · GitHub
重複したパッケージのリダイレクト
簡単に言うとnode_modulesのパッケージの同一性チェックをする。
つまり、moduleResolutionがnodeの場合node_modulesからモジュールをインポートするが、
その際にそのモジュールのpackage.jsonをチェックして、すでにインポートした事のあるモジュールかどうか確認する。
もし同一のモジュールであれば、すでにimport済みのモジュールにリダイレクトして同じモジュールをロードしない。
ちなみにpackage.jsonのversionとnameをチェックして同一性判定をする。
node_modules - | [A-package] - | | | node_modules - | | | [C-package] [B-package] - | node_modules - | [C-package]
この例だと、A-packageとB-packageが同じC-pacakgeを必要としているが、typescriptコンパイラは一度しかC-packageをインポートしない。
B-packageの必要とするC-pacakgeはA-packageのC-packageを参照する。
–preserveSymlinksコンパイラフラグの導入
–preserveSymlinksオプションが導入される。
このオプションはシンボリックリンクされたモジュールが他のモジュールをimportする場合に、
シンボリックリンク元のパスを起点とした相対パスではなく、
シンボリックリンクが置かれている場所からの相対パスとなる。
A - | B - | | | C'(Symblink of C) C
この例だと、C'モジュールのパス解決は–preserveSymlinksをセットしない場合は、
A/Cから始まることになる。
しかし–preserveSymlinksをセットすると、
A/B/C'からパス解決が行われる。
まとめ
意外と新機能は少なかったですね。
typescript 2.4 の新機能
typescript2.4がでたので新機能を確認。
Dynamic Import Expressionsのサポート
import('...')
式がサポートされた。
import
式を使うことで多くのバンドラーがコード分割をすることが可能になるので、
module: esnext
で出力するのがおすすめだそう。
async function getZipFile(name: string, files: File[]): Promise<File> { const zipUtil = await import('./utils/create-zip-file'); const zipContents = await zipUtil.getContentAsBlob(files); return new File(zipContents, name); }
String Enumsのサポート
待望?の文字列enumがサポートされた。
enum Colors { Red = "RED", Green = "GREEN", Blue = "BLUE", }
ただし制限として、数値enumの際に可能だった、メンバプロパティからプロパティ名の取得はできない。
Colors[Colors.Red] // これはできない。
interfaceのgeneric型のサポートを強化
戻り値の推論能力の強化
戻り値から型パラメータを導出できるようになった。
function arrayMap<T, U>(f: (x: T) => U): (a: T[]) => U[] { return a => a.map(f); } const lengths: (a: string[]) => number[] = arrayMap(s => s.length);
Promiseもこのようにエラーとすることができるように。
let x: Promise<string> = new Promise(resolve => { resolve(10); // ~~ Error! });
文脈からの型パラメータの導出
いかのような定義があった場合
let f: <T>(x: T) => T = y => y;
y
はany
になってしまっていた。
そのため、
let f: <T>(x: T) => T = y => y() + y.foo.bar;
のような式は型チェックをスルーしてしまっていたが、
2.4からy
が正しく導出され、エラーとなるようになった。
generic関数の方チェックを厳格化
以下のような関数があった場合、
type A = <T, U>(x: T, y: U) => [T, U]; type B = <S>(x: S, y: S) => [S, S]; function f(a: A, b: B) { a = b; // Error b = a; // Ok }
AがBと互換性があるかをチェックできるようになり、互換性があれば、代入も可能になった。
コールバック関数の方の反変性チェックを厳格化
2.4以前のバージョンでは以下のプログラムではエラーが起きなかった。
interface Mappable<T> { map<U>(f: (x: T) => U): Mappable<U>; } declare let a: Mappable<number>; declare let b: Mappable<string | number>; a = b; b = a;
なぜなら、a
とb
がmap関数のパラメータf
を通して変換可能であると判断されていたため。
しかし2.4以降は実際のa
とb
の型を比較を行うため、このプログラムはコンパイルエラーとなる。
この変更は破壊的変更となるので注意
Weak Typesの導入
以下のように、すべてがoptionalな型をWeakTypeとして区別することになった。
interface Options { data?: string, timeout?: number, maxRetries?: number, }
2.4からはこのWeakTypeに対しても、存在しないプロパティを持つ型を代入するとエラーになる。
function sendMessage(options: Options) { // ... } const opts = { payload: "hello world!", retryOnFail: true, } // Error! sendMessage(opts); // optsとOptions型で一致するプロパティがないためエラー
この変更は破壊的変更となるので注意
現在以下のWorkaroundが提案されている。
- 実際にプロパティが存在する場合のみ宣言する。
- WeakTypeには
{[propName: string]: {}}
のようなインデックス型を定義する。 opts as Options
のように型アサーションを使って変換する。
まとめ
型チェックの強化がメインの変更点になった。
破壊的変更が幾つかありますが、WeakTypeのとこはちょっと注意した方が良さそう。
あとはimport
式をどう使うか。