1 - 開発環境の構築
依存ライブラリ、ツール
- cmake
- bison 3.3~
- flex
- llvm 11~
- libsndfile
- rtaudio(cmakeが自動でインストールします)
加えて、Ninjaをインストールするとビルドが早くなる可能性があります。
macOS
XCodeをインストールし、以下のコマンドでコマンドラインツールをインストールしてください。
xcode-select --install
[Homebrew]をインストールし、以下のコマンドで依存パッケージをインストールします。
brew tap mimium-org/mimium
brew install mimium -s --only-dependencies
Linux(Ubuntu)
ターミナルから以下のコマンドで依存パッケージをインストールします1。
pushd /tmp && wget https://apt.llvm.org/llvm.sh && chmod +x llvm.sh && sudo ./llvm.sh && popd
sudo apt-get install libalsa-ocaml-dev libfl-dev libbison-dev libz-dev libvorbis-dev libsndfile-dev libopus-dev gcc-9 ninja-build
LinuxBrewを利用している場合はmacOSを同様のコマンドでも依存パッケージのインストールが可能です。
Windows
MSYS2(https://www.msys2.org/)のインストールを公式サイトの要領にしたがって行ってください。 スタートメニューからMinGW64コマンドプロンプトを開いてください。
MinGW64 Command Line Promptを開き、以下のコマンドで依存パッケージをインストールします。
pacman -Syu --noconfirm git flex bison mingw-w64-x86_64-cmake mingw-w64-x86_64-gcc mingw64/mingw-w64-x86_64-libsndfile mingw64/mingw-w64-x86_64-opus mingw-w64-x86_64-ninja mingw-w64-x86_64-llvm
リポジトリのクローン
gitコマンドが使えることを確認し、次のコマンドでmimiumの開発リポジトリをクローンします。
git clone --recursive https://github.com/mimium-org/mimium.git
エディタ
mimiumの開発にはVisual Studio Codeを利用することでどのOSでも同じように開発できるようになっています。
mimiumディレクトリでmimium.code-workspace
をVS Codeで開いてください。
推奨拡張機能
ワークスペースを開くと、推奨される拡張機能がインストールされていなければポップアップメニューが現れインストールできます。
- cmake-tools
- clangd
- CodeLLDB
- Coverage Gutter
とくにCMake Toolsは必須の拡張です。
CMake Kitの設定
cmake-toolsがインストールされている状態でワークスペースを開くと、初回のみCMakeが使用するKit(コンパイラ)を選ぶメニューが出てきます。
macOSでは/usr/bin/clang
を選択してください。Linuxでは/usr/local/bin/g++
、MinGW64なら/mingw64/bin/g++
などそれぞれインストールしたC++コンパイラへのパスを指定します。
ビルドする
VS Code左側のメニューからCMake Toolsのタブを選択し、Configure Allを選択します。build/
以下にビルドディレクトリが構築されます(この時、RtAudioのダウンロードとビルドも自動的に行われます)。
Configure Allの右隣、Build Allを押すと全プロジェクトのビルドが始まります。
ターゲット一覧
src/mimium_exe
: メインのmimium
コマンドをビルドします。src/mimium
: ライブラリの本体、libmimium
をビルドします。test/Tests
: すべてのテスト(Fuzzingを除く)をビルドするためのターゲットです。Lcov
LcovResetCounter
:-DENABLE_COVERAGE
を有効化した時利用可能なターゲットです。1回以上テストなどをビルドした上で実行した後Lcov
ターゲットを実行するとカバレッジ情報を収集します。この状態でCmd+Shift+PのコマンドパレットからShow Coverage
を選択するとコードを実際に実行した部分がハイライトされます。ソースを編集して再ビルドした後はカバレッジ情報のコンフリクトが発生するので、LcovResetCounter
を実行する必要があります。
VS Codeでのデバッグ方法
VS Code左側のメニューからRunのタブを選択します。 Runの実行ボタンの隣からコンフィグが選択できます。ワークスペースにはCMakeのターゲットを起動するもの(“CMake Target Debug(Workspace)")と、それに加えてコマンドラインオプションを指定して実行するもの(“Launch with Arg(edit this config)(workspace)")の2つが存在します。
下メニューバーの"Select the target to launch"をクリックすることで、CMakeのどのターゲットを起動するかを選択できます。
コマンドラインにオプションを渡して起動したい場合、“Launch with Arg(edit this config)(workspace)“を選択してからさらに歯車マークでmimium.code-workspaceファイルを直接編集します。たとえば以下の例のように"args"にファイル名を指定するように使います。
...
{
"name": "Launch with arg(edit this config)",
"type": "lldb",
"request": "launch",
"program": "${command:cmake.launchTargetPath}",
"args": ["${workspaceFolder}/examples/gain.mmm"],
"cwd": "${workspaceFolder}/build",
},
...
他にも"stdio": ["${workspaceFolder}/stdin.txt",null,null],
のようにすると、標準入力から入力を受け付けることも可能です。
テスト
テストには主にGoogle Testを利用した、3種類のテストが存在します。
ユニットテスト
主にコンパイラの各セクションを単独で起動して正しく動作するかのテストです。
回帰テスト(Regression Test)
実際にビルドしたmimium
バイナリをstd::system
関数から起動して、新機能追加に伴って他の箇所にエラーが発生しないかチェックするためのテストです。
mimiumには現在テストを検証するための構文などが存在しないため、計算結果をprintln
関数で標準出力にパイプし、Google Testでそれをキャプチャして正しい答えが出るかを検証するという形をとっています。
また現在オーディオドライバを起動するテストは未実装です。
Fuzzingテスト
Clangのlibfuzzerを利用するファジングテストです。 ファジングテストとはランダムな入力(ここではソースコードの文字列のことです)を少しずつ変化させながら与え、正しい構文なら正しく処理され、エラーの場合はエラーとして処理され予期せぬクラッシュが発生しないかなどの検証に使用されています。
macOSでのみ検証されており、CIには含まれていません。
Ctestの実行
buildフォルダでctest
コマンドを実行するか、VS Codeの右下のメニューから"Run Ctest"をクリックするとユニットテストと回帰テストが実行されます。
UbuntuでLLVMをインストールするときは、例のように https://apt.llvm.org/ にある自動インストールスクリプトの利用をお勧めします。これはaptにある
llvm
パッケージがPolly
関連のライブラリがllvm-config --libs
コマンドで要求されるにもかかわらず含まれていないためです。参照. ↩︎
2 - コーディング方針
基本的方針
- 言語仕様はC++17に準拠する。主な理由は
std::variant
やstd::optional
を積極的に利用するため、テンプレートの推論がif constexpr
などで可読性が向上するため。 - 可読性かわずかな実行速度の向上で迷ったら可読性を取る。 そもそもC++の時点である程度速いことが保証されているので多少は富豪的処理でも構わないし、その程度の迷いは大体最適化されるとほぼ同じになる。
- できるだけ外部ライブラリを用いない(とくにboostなど汎用的なものについては)。STLはなるべく積極的に活用する。
- コンパイラにはパーサーでbison(yacc)とflex(lex)に依存している。これはとくにbisonのソースのドキュメントとしての価値があるため今後も利用していくつもりだが、flexに関してはUnicodeが読み込めないなどの問題があるためREflexなどに移行するか手動実装に切り替えるかもしれない。要検討。
- ランタイムには現在オーディオファイル読み込みでlibsndfileなどに依存しているがこれは実行時にしか必要ないので後々プロジェクト構造として分離していきたい。
- 生ポインタは使わない。基本的に
std::shared_ptr<T>
を使用する。LLVMライブラリにおけるllvm::Type*
やllvm::Value*
は独自に参照カウントが実装されているためこの限りではない。 - ポインタ変数が空であることを
nullptr
を用いてifの条件式などで使用しない。多少手間がかかってもstd::optional<std::shared_ptr<T>>
の形をなるべく使うことでソースコードそのもののドキュメント的価値を向上させる。
動的多相 (Dynamic Polymorphism)
C++に置いて型ごとに個別の処理内容を分ける 多相(ポリモーフィズム) にはコンパイル時に型を確定させる静的多相と実行時に動作を確定する動的多相の2種類が存在します。静的多相は主にテンプレートによって実現され、動的多相は主に継承と仮想関数を用いて実現されます。
しかしmimiumの開発では動的多相に仮想関数を基本的に使用しません。代わりにC++17よりSTLに導入されたstd::variant
を用います。std::variant<T1,T2,T3...>
はT1~Tnの複数種類のどれかの型を持つ変数を代入できる型であり、std::get<T>
やstd::visit()
を用いることで動的に型に応じての処理を分けることが可能になります。これは関数型などでよく見られる 直和型 と呼ばれる型の代わりでもあり、std::visit
はテンプレートやconstexprを用いた処理分けと組み合わせるといわゆるパターンマッチングに近い処理を可能にします。内部実装的には取りうる型の最大値のメモリ分+現在どの型を保持しているのかのタグ(整数)を確保する形になっているので Tagged Unionとも呼ばれます。
mimiumにおける具体的な型でいうと抽象構文木であるmimium::ast::Expr
やmimium::ast::Statement
、中間表現であるmimium::mir::Instruction
、(mimium言語における)型を表すmimium::types::Value
などがstd::variant
へのエイリアスです。
仮想関数よりもvariant
を用いるメリットはなんでしょうか?1つは、実行コストがかかることです。仮想関数は実行時に関数へのテーブルを保持し仮想関数が呼び出されるたびにそれを参照する必要があるため、std::variant
を用いた多相の方が実行速度では一般的に有利だとされています。
もう1つはアップキャストの問題です。抽象構文木のような木構造のデータを渡りながら処理をする時、どうしても
- 継承した個別の型を基底クラスのポインターでダウンキャストして受け取る
- 仮想関数を用いて型に応じた処理をする
- 処理したあと帰ってきたポインターを元の型に戻し(アップキャスト)、さらに処理を続ける
といったパターンが発生します。このアップキャストは一般的なコーディングでは、間違った型へキャストして終えば予測不可能な挙動が起きるので御法度とされています。仮想関数で用いている実行時型情報(RTTI)を用いるdynamic_cast
を使って動的に型検査をして安全にアップキャストする方法もありますが、記述が長くなりがちなども問題もあります。一方でstd:variant
を使用するとこのようなダウン→アップキャストの必要はないので型情報が明確に取り扱えます。
またこうした(型に応じた処理を複数種類)x(複数の型)と組み合わせる方法はデザインパターンの中ではビジターパターンとも呼ばれ、 std::visit(function_object,variant_variable)
ではこの形が引数としてシンプルに表されています。一方仮想関数を使ってのビジターパターンはデータ側にacceptと呼ばれるメソッドを実装しておく必要があるので、データはデータ、関数は関数というように構造を分離することが難しくなります。
再帰的データ構造
std::variant
にも難しい点はあります。そのうち重要な点は再帰的データ構造がそのままでは扱えないことです。1
たとえば、types::Value
はtypes::Float
やtypes::Function
など取りうる型すべてを含んでいますが、ここでtypes::Function
のメンバ変数にはたとえば返り値を表す型としてtypes::Value
が含まれてしまっています。
std::variant
は通常の数値型のデータなどと同じように、取りうる型の最大値分だけメモリをスタック確保し、ヒープアロケーションは行わない仕様となっており、再帰的なデータ構造の場合はデータサイズを静的に決定できなくなってしまうのでコンパイルできなくなります。
これを回避するためには、再帰的な部分を持つデータについては実体の代わりにポインタを格納するなどの方法が考えられるのですが、初期化やメンバアクセスなどが統一されないためややこしくなるなどの問題があるため、mimiumでは次の記事を参考にしたヘルパークラスを利用しています。
具体的には内部のT
の実体を要素数1のstd::vector<T>
に確保し、キャスト演算子としてT& ()
を実装しimplicitに元の型から構築、キャストして参照を受け取ることができるようにしています。std::vector
はT
の中身が不完全なままでもスタック上のデータサイズを確定させることができるのでコンパイルが通るようになるのです。
記事中のrecursive_wrapper<T>
を、mimium内部ではRec_Wrap<T>
と名付け、さらにこの型をrT
(たとえばFunction
に対してrFunction
といった形で)エイリアスしています。
using Value = std::variant<None, Void, Float, String, rRef, rTypeVar, rPointer, rFunction,rClosure, rArray, rStruct, rTuple, rAlias>;
/*~~~~~*/
using rFunction = Rec_Wrap<Function>;
/*~~~~~*/
struct Function : Aggregate {
Value ret_type;
std::vector<Value> arg_types;
};
たとえばstd::visit
でパターンマッチングする時にはビジターの関数オブジェクトのoperator()
オーバーロードを以下のようにします。
types::Value operator()(types::Function& f){
return someProcess(f);
}
/*~~~~~*/
template<typename T>
types::Value operator()(Rec_Wrap<T>& t){
return (*this)(static_cast<T&>(t));
}
Rec_Wrap<T>
を一度キャストして剥がしてもう一度自分自身を適用するテンプレート関数を使います。operator()(Rec_Wrap<types::Function>& rf)
を直接オーバーロードしても構わないのですが、このrf
は直接メンバアクセスができないため一度ローカル変数でtypes::Function&
にキャストしてやらないといけなかったりする二度手間が発生します。
エラー処理、例外
try
、throw
、catch
と言ったC++標準のエラー処理を積極的に利用します。throw
を使ったエラー処理は実行コストが高いというデメリットがあるものの、正常系で処理をしている間はコストがかからず、言語組み込みの仕様なのでエラー処理部の可読性が高まります。
optionalなどを活用したエラー処理クラスの実装は型シグネチャが複雑になり可読性が下がるなどのデメリットもあります。ただ内部的に使用しているLLVMのライブラリでは実行速度を重視してExpected
クラスなどでこの形式でエラー処理をしているため、こちらとtry
、catch
のエラー処理を混ぜるのはあまり効率がよくはない、混乱するといった問題点も存在しています。
現状ではコンパイラもランタイムもtry/catchのエラー処理を使用していますが、ランタイム側では実行時間にセンシティブである、今後組み込み向けなどに移植される可能性もあると言った事情を考えるとoptional等を活用した処理に変更するかもしれません。
基本的にtry
,catch
はコンパイラでの処理のはじめに行い深くネストすることはしません。どの道、ほとんどのケースでエラーが起きれば最終的にコンパイルエラーの形に帰着するためです。必要に応じてエラークラスを継承して種類分けし、catchしたところでまとめてエラーの型ごとにメッセージ等を個別に処理します。( エラークラスの定義は今後の課題)
std::variantの実装の元となったBoostには再帰データを扱えるrecursive_variantが存在していますが、今のところそのためだけにboostを利用するくらいならばヘルパークラスを一つ用意することで解決するという方針をとっています。 ↩︎