tak0kadaの何でもノート

発声練習、生存確認用。

医学関連は 医学ノート

「Inside the C++ Object Model」を読んだ

C++ Primerの著者の一人である S. Lippmanの著書。C++コンパイラの前身?であるcfrontはC++→Cのトランスパイラとして実装されており、cfront2くらいの頃の変換規則を解説しており、多重継承を含む多態性をどのように実装したかに重点が置かれている。研究室に来ていた留学生に勧められてこの本を手に取ったが、C++ Primerと重なる部分が多く感じられ、自分が他人に勧めるかと聞かれるとあまりおすすめしない(中古の割に高い: amazon)。むしろhttps://shaharmike.com/cpp/vtable-part1/~part4、stackoverflowにあったc++ - What is the VTT for a class? - Stack Overflowという記事、日本語ではメモリ配置とキャスト – wizaman's blogなどの記事が分かり良いと感じた。以下は記載内容を適当に省略しつつ発生させたメモの写し(最新の実装との整合性のチェックなどはしていないし、間違いもあっても構わんやろという立場)

  • 基本的にはCと同様のレイアウトになる(eg. PODType)
  • オーバーヘッドなしが原則だが多態性などはオーバーヘッドあり
  • データメンバは宣言の順に並ぶ
  • メモリのレイアウトには非staticデータメンバのサイズが分かること、パディングの量、仮想テーブルへのポインタが必要
  • 参照は内部的にはポインタで実装されている
  • 多態性はポインタ越しに実現されている
    • 実体は静的に処理される
    • ポインタは型に依存するメモリ消費がなく一定なので実現できた
  • 派生型のインスタンスは基底型に代入すると、派生部分はスライスされてなくなってしまう
  • Schwarzエラー: 暗黙の型変換によるバグ
    • case1. cin << intval
    • case2. int hoge(){return some_other_type}→some_other_typeはintに変換されてしまう
  • trivialなコンストラクタとはインスタンス生成時に何もしないコンストラクタのこと(メモリの確保はする)
  • グローバル名前空間内のオブジェクトはゼロ初期化が保証される。スタック、ヒープ内のオブジェクトについては保証なし
  • =defaultとするとデフォルトコンストラクタetcが適用される(こんな記載ないはずでは...)
  • ユーザ定義のコンストラクタがあるとほげほげ(参照: The rule of three/five/zero - cppreference.com)
  • メンバ変数の初期化は宣言された順に行われる
  • 継承を含む場合、コンストラクタの合成が必要(usingが使えるように改善あり: 継承コンストラクタ - cpprefjp C++日本語リファレンス)
  • 仮想関数を含む場合もコンストラクタの合成が必要(これも含め以下コンパイラにとって合成が必要という意味だったかも)
  • ビットコピーが出来る場合はコピーコンストラクタは合成されない
    • 出来ない場合→ユーザ定義のコピーコンストラクタが必要
    • コピーコンストラクタ付きのクラスをメンバに持つ
    • コピーコンストラクタを持つクラスを継承
    • 仮想関数がある
    • 仮想基底クラスを持つ
  • 継承、virtualがあってもシングルトンならビットコピーでも問題なし
  • プログラムの結果が変わらない最適化はコンパイラの裁量で実施される
  • メンバ初期化リストを使う
    • 組み込み型についてはどこに書いても同じ
  • 初期化は初期化リストの順ではなく宣言順
  • クラスのサイズ(sizeof)
    • class X{}; -- 1
    • class Y: public X{}; -- 1
    • class Z: virtual publlic X{}; -- 8(1(X)+3(パディング)+4(vptr))
    • class W: virtual public Z{}; -- 8
    • class A: public Z, public W{}; -- 12(1(X)+4(Z)+4(W)+3(パディング))
    • sizeof(X)が1なのはX a; X b;としてa != bを保証するため
  • nonstaticなデータメンバはオブジェクトのサイズに影響する
  • staticなデータメンバはオブジェクトのサイズに影響しない
  • メンバ変数の定義、宣言はスコープ全体を参照するがメンバ関数は上の行のみ
  • class X{public int x; public int y;}というクラスがあったとして、xとyの並びは保証されない(実際にはxyの順に並ぶ)
  • メンバ変数、関数には「メンバへのポインタ」を使ってアクセスする
    • T Class::*p = &class::mem
    • メリットfunc(void func1, void func2)よりfunc(T1 Class::func1, T2 Class::func2)の方が分かりやすい
    • staticメンバ関数はT::func1、non-staticメンバ関数は&T::func
    • 関数の引数の型にメンバ関数ポインタを使うのは微妙
    • c.f. https://isocpp.org/wiki/faq/pointers-to-members
  • vptrを先頭に置くとCとの互換性はなくなるが実行時のvptrのアドレスは静的に解決できるので実行効率は良い
  • アップキャスト: 継承先→基底、ダウンキャスト: 基底→継承先
  • 多重継承は宣言された順にメモリ上に配置される
  • 仮想継承→共通部分(仮想基底クラス)+不変部分
  • non-staticなメンバ関数は非メンバ関数と同じ実行コスト
  • メンバ関数はオブジェクトを引数に取るプログラムに変換される
  • 現在のコンパイラ、リンカではデマングリングされた変数名がエラー時に報告される
  • 仮想関数でもオブジェクトから直接呼び出されるものは静的に解決できる
  • staticメンバ関数が渡される前はthisポインタの代わりにヌルポインタに渡していた
    • eg. ((Point3d*) 0)->obj_count();
  • staticメンバ関数はprivateメンバ変数へのアクセスで多く用いられる
  • 継承があってもvirtualでないメンバ関数はポインタの型に合わせて静的に解決される
  • 純粋仮想関数があればvtableにpure_virtual_calledという関数が登録され、誤って純粋仮想関数を呼び出してしまった際のエラーに用いられる
  • 多重継承では実行時にthisポインタを適切な位置に合わせる必要あり
//Base1←Derivedと継承

Base1 *pb1 = new Derived;
↓
Derived *tmp = new Derived;
Base1 *pb1 = tmp?tmp+sizeof(Base0):0;
//ポインタ経由でデストラクタを呼ぶときは
delete pb1
↓
(*pb1->vptr[1])(pb1) // 普通の場合
↓
(*pb1->vptr[1].faddr)(pb1+pb1->vptr[1].offset) //pb1がDerivedを指している時
// vptr[1]にデストラクタへのポインタでなく「デストラクタへのポインタとオフセット」を登録しておく
  • thisのオフセットはgotoを利用する方法もある(thunkと呼ばれる)
  • vtableの名前はマングリングの際には継承関係が用いられる
    • vtable_Derived, vtable_Base2_Derived
  • 仮想基底クラス中ではnon-staticなデータメンバを宣言しないほうが良い
//メンバへのポインタ
struct Point {
  virtual ~Point();
  float x();
  float y();
  virtual float z();
};

&Point::~Point //1
&Point::x //アドレス
&Point::z // 2
(ptr->*pmf)();
↓
// 仮想関数が128個までなら以下の実装でok
((int)pmf & ~127)
? //非仮想
(*pmf)(ptr)
: //仮想
*ptr->vptr[(int)pmf](ptr);
↓
//もう少し上品な実装
struct _mptr{
  int delta; // thisポインタのオフセット
  int index; //仮想関数のインデックス(仮想関数でなければ-1)
  union{
    char *faddr; // 関数のアドレス
    int v_offset; // vptrのオフセット
  };
};
(pmf.index<0)
? //非仮想
(*pmf.faddr)(ptr)
: //仮想
*(prt+pmf.v_offset)[pmf.index](ptr+pmf.offset);
  • inline定義すればコンパイラ次第で呼び出しコストをなくすことが可能(必ずなくなる訳ではない)
  • inline化出来なければstaticな関数に変換される
  • 純粋仮想関数は宣言だけでなく、静的に解決される定義を持つことが出来る
  • デストラクタも純粋仮想関数に出来るが、必ず定義が必要で、なければリンク時にエラーになってしまう
class Base{
  virtual void f() = 0;
  ~Base(){f()}; // Base::fと書けば静的解決可能、fのみだと仮想関数呼び出し→エラー
};
Base::f(){cout << "hoge" << endl;}
  • POD、非PODのコンストラクタ
    • Point *p = new Point;
    • PODなら: Point *p = __new(sizeof(Point));
    • 非PODなら: Point *p = __new(sizeof(Point)); p->Point::Point();
  • PODでなければvptrやvtableなどのコピーのため非自明なコンストラクタが生成される
  • operator=は自己代入チェックをしておく必要あり
  • 仮想継承では親クラスが仮想クラスの初期化に責任を持つ
    • A←(仮想継承)B←Cの時、CがAを直接初期化する。普通の継承だとAを初期化するのはB
// Point <-v- Point3d, Point <-v- Vertex, Point3d, Vertex <- Vertex3d, Vertex3d<-PVertexという菱形継承のとき
Vertex3d::Vertex3d(Vertex3d *this, bool most_derived, float x, float y, float z) {
  if (most_derived != false)
     this->Point::Point(x,y) //仮想基底クラスの初期化
  this->Point3d::Point3d(false, x, y, z);
  this->Point3d::Vertex(false, x, y);
  //set vptr
  //user code here
}
  • 仮想関数の呼び出しは派生クラスから呼び出された場合でも、基底クラスの構築中・破棄時は基底オブジェクトの関数に解決される
  • オブジェクトの構築は基底クラス、vptr、データ(初期化リスト)、データ(デフォルトコンストラクタ)、コンストラクタの中身の順
  • コンストラクタの初期化リスト内で仮想関数を呼ぶのはよろしくない
    • case1: 派生クラスのメンバの場合初期化前の変数が利用されてしまう
    • case2: 基底クラスの場合、vptrが誤ったクラスに設定されている可能性あり
  • コピーコンストラクタでは初期化リストは使えない
  • コピーコンストラクタの実行中、同じ基底クラスに複数回コピーが起こるのは抑制できない
    • 上書きを防ぐため派生クラスから順にコピーを実施する
  • オブジェクトの破棄はvptrのリセット、ユーザの書いたデストラクタ内のコード、メンバ変数のデストラクタ、非virtualな基底クラスのデストラクタ、virtualな基底クラスのデストラクタの順
  • 基本的にオブジェクトはスコープを抜けるときに破棄される、関数ならreturnの直前
  • グロオーバルオブジェクトはmainに入る前に初期化が必要、cfrontではグローバルオブジェクトの初期化専用の関数(__sti(static initialization))、デストラクタ(__std(static deallocation))を合成していた
  • 静的に初期化されたオブジェクトの使用時にエラーが生じるとプログラムは終了してしまう
  • Cでは静的な初期化は出来ない?
  • Point knots[10]Point knots[10]; vec_new(&knots, sizeof(Point), 10, &Point::Point, 0)
  • 長さ0の配列でもメモリは1バイト消費される
  • delete pのは省略不可、昔は[]の中に要素数まで書く必要があった
  • 一次変数の生存期間を企画で定義していない頃は式の評価が終わる前に一次変数が破棄される可能性があった
    • 式の評価が終わるまでは破棄しない
    • 初期化が終わるまで一次変数を破棄しない
  • テンプレート
    • 文法チェック
    • インラインの(非メンバ関数、メンバ関数)の実体化、各コンパイル単位
    • 非メンバ関数、メンバ関数、staticメンバ関数の実体化、実行ファイルにつき一度だけ
  • テンプレートへのポインタではテンプレートは実体化されない
  • テンプレートへの参照では実体化される
  • 使われないメンバ関数は実体化されない
  • テンプレートには2つのスコープ(宣言部分と実体化が実行される部分)がある。実体化の際の名前解決は
    • テンプレートパラメータと関係ないもの: 普通の解決
    • テンプレートパラメータと関係するもの: テンプレートパラメータに応じて解決
  • 仮想関数を 使う→vtableが必要→その関数のアドレスが必要→全ての仮想関数の実体化が必要
  • リソースを確保するときは
    • 確保を行うクラスを使う
    • try内で実行、catch内でdelete, unlockする
  • 例外は参照でcatchする
    • 値渡ししてしまうと、例外オブジェクトが継承されている場合にスライスされてしまう他、さらにthrowされた場合に新しい(これまでの情報を含まない)例外オブジェクトが新しく生成される
  • RTTI = runtime type identification
  • RTTIを実施するのは多態をサポートするもの、つまりvptrありのクラス。vtableに型情報を入れたオブジェクト(type_info)を登録しておく
  • dynamic_castはRTTIを利用してダウンキャストを安全に実行する
  • dynamic_castは参照で使用しない。キャストできなかった場合にエラーになる(ポインタだとヌルポインタが返る)
  • typeid演算子はtype_infoを参照する。多態性がない型に対しては静的に解決される
  • 最後にdynamic shared library、shared memory、C++ Object Modelに言及しているがよく分からず

参考

追記(20190222)