This the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Development

開発環境の構築、コンパイラ・ランタイムの構造やクラスなどの解説。

Development

1 - 開発環境の構築

依存ライブラリのインストール、IDEの設定、ビルド、デバッグ、テストの解説

依存ライブラリ、ツール

  • 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"をクリックするとユニットテストと回帰テストが実行されます。


  1. UbuntuでLLVMをインストールするときは、例のように https://apt.llvm.org/ にある自動インストールスクリプトの利用をお勧めします。これはaptにある llvm パッケージが Polly 関連のライブラリが llvm-config --libs コマンドで要求されるにもかかわらず含まれていないためです。参照. ↩︎

2 - コーディング方針

C++のコーディング規約、方針について

基本的方針

  • 言語仕様はC++17に準拠する。主な理由はstd::variantstd::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::Exprmimium::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::Valuetypes::Floattypes::Functionなど取りうる型すべてを含んでいますが、ここでtypes::Functionのメンバ変数にはたとえば返り値を表す型としてtypes::Valueが含まれてしまっています。

std::variantは通常の数値型のデータなどと同じように、取りうる型の最大値分だけメモリをスタック確保し、ヒープアロケーションは行わない仕様となっており、再帰的なデータ構造の場合はデータサイズを静的に決定できなくなってしまうのでコンパイルできなくなります。

これを回避するためには、再帰的な部分を持つデータについては実体の代わりにポインタを格納するなどの方法が考えられるのですが、初期化やメンバアクセスなどが統一されないためややこしくなるなどの問題があるため、mimiumでは次の記事を参考にしたヘルパークラスを利用しています。

Breaking Circular Dependencies in Recursive Union Types With C++17 - Don’t Compute In Public(last view:2020-08-17)

具体的には内部のTの実体を要素数1のstd::vector<T>に確保し、キャスト演算子としてT& ()を実装しimplicitに元の型から構築、キャストして参照を受け取ることができるようにしています。std::vectorTの中身が不完全なままでもスタック上のデータサイズを確定させることができるのでコンパイルが通るようになるのです。

記事中の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&にキャストしてやらないといけなかったりする二度手間が発生します。

エラー処理、例外

trythrowcatchと言ったC++標準のエラー処理を積極的に利用します。throwを使ったエラー処理は実行コストが高いというデメリットがあるものの、正常系で処理をしている間はコストがかからず、言語組み込みの仕様なのでエラー処理部の可読性が高まります。

optionalなどを活用したエラー処理クラスの実装は型シグネチャが複雑になり可読性が下がるなどのデメリットもあります。ただ内部的に使用しているLLVMのライブラリでは実行速度を重視してExpectedクラスなどでこの形式でエラー処理をしているため、こちらとtrycatchのエラー処理を混ぜるのはあまり効率がよくはない、混乱するといった問題点も存在しています。

現状ではコンパイラもランタイムもtry/catchのエラー処理を使用していますが、ランタイム側では実行時間にセンシティブである、今後組み込み向けなどに移植される可能性もあると言った事情を考えるとoptional等を活用した処理に変更するかもしれません。

基本的にtry,catchはコンパイラでの処理のはじめに行い深くネストすることはしません。どの道、ほとんどのケースでエラーが起きれば最終的にコンパイルエラーの形に帰着するためです。必要に応じてエラークラスを継承して種類分けし、catchしたところでまとめてエラーの型ごとにメッセージ等を個別に処理します。( エラークラスの定義は今後の課題)


  1. std::variantの実装の元となったBoostには再帰データを扱えるrecursive_variantが存在していますが、今のところそのためだけにboostを利用するくらいならばヘルパークラスを一つ用意することで解決するという方針をとっています。 ↩︎