コーディング方針

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を利用するくらいならばヘルパークラスを一つ用意することで解決するという方針をとっています。 ↩︎


最終更新 10.01.2021: fixed menuweights (e0c18df)