自作クラスを実体としてメンバ変数に持つ時の挙動

概要

前回のあらすじ

soluna-eureka.hatenablog.com

分割コンパイルでモジュール開発をする場合は,ライブラリのシンボル指向実装とinclude地獄の回避可能なヘッダ記述手法の都合から,モジュールが異なる(レイヤが1つ下の)自作クラスをメンバ変数に持つなら,実体ではなくポインタで持った方が良く,かつそれらはnewdeleteを使用してメモリ管理をちゃんと実装し,コンストラクタもデストラクタも確認するべし,的な結論に至った(c++98縛りゆえスマートポインタ不使用)

しかしモジュールの開発においては,同一レイヤのレベルで自作クラスを実装して,そのまま自作クラスのメンバ変数に組み込む,みたいな場合があるかもしれない(,むしろそれが基本的なクラス設計のやり方なのでは…?)

今回のあらすじ

  • 同じモジュール内で実装したクラスでも,ポインタで持つ方が安心できる
    • 面倒でもオブジェクトはnewdeleteする癖をつけた方が良いかもしれない
    • そもそもそういうやり方を統一した方が混乱しない,組み込み型じゃないなら尚更
  • 実体で持つ場合は必ず=の定義を確認するべし
    • 例えば,「「クラスx」を抱えるクラスy」をオブジェクトにする場合,クラスyのコンストラクタが走る前にクラスxのデフォルトコンストラクタが走る,自作クラスを実体で持つ限りこの挙動は避けられない
    • クラスyのコンストラクタ内でクラスxのコンストラクタを叩き,その結果をメンバ変数に=で接続すると,クラスの初期化と同様に反映できる気もするが,これは初期化の文法ではなく代入の文法として動作する
      • なぜなら実体で持つメンバ変数はコンストラクタを叩く前にデフォルトコンストラクタが走って既に実体化したから,その動作はコンストラクタで得たオブジェクトをオブジェクトに=で代入することを意味する
      • もちろんコピーコンストラクタでもない,例えコピーを名乗ってもコンストラクタは2度と走らない
    • つまり演算子=が曖昧な自作クラスを実体としてメンバ変数に持つと予期せぬ動作が起きるかも…
      • 自作行列ライブラリ(公開できない)の開発中にコレに遭遇して,初めてちゃんと仕様を確認した…
    • 1つ下のレイヤのオブジェクトのメンバ変数を叩いて加工するよりも,最初からそのオブジェクトに固有の演算子=を定義した方が,実装の責任境界が明確だし後で他のクラスに使われる時も好都合だしで生産性がある

コピー代入演算子のみを扱う,ムーブ代入演算子は扱わない ただし速度向上を考えたら後者の方が良いのかもしれない…

演算子=オーバーロードが必要なケース

  • 他のクラスに実体で持つ予定のクラス
    • そうじゃないなら要らないかも…
  • 算術演算みたいな使い方をするクラス
    • 行列クラスとかそもそも不可避…
    • 引数の設定
  • メンバ変数にポインタを持つクラス
    • newdeleteまたはmallocfreeでメモリ確保したポインタを持ってその中でデータを保持している場合,ポインタをコピーするのかポインタの中身をコピーするのかちゃんと考えて実装しよう

実装

.
├── bin
│   └── main
├── libDataBase
│   ├── inc
│   │   └── libDataBase.hpp
│   ├── lib
│   │   └── liblibDataBase.a
│   └── src
│       ├── framework.cpp
│       ├── framework.hpp
│       ├── interface.cpp
│       └── interface.hpp
├── makefile
└── src
    └── main.cpp
SHELL     =   /bin/zsh

MAIN_SRC    =   src/main.cpp
MAIN_OBJ    =   main.o
MAIN_BIN    =   bin/main

GCC_BIN     =   clang++
GCC_FLG     =   -std=c++11 -O2


make            :
    make libDataBase
    make main


libDataBase     :   FORCE
    $(call setAllVar,libDataBase)
    $(GCC_BIN) $(GCC_FLG) -c $(libDataBase_SRC)
    @ar cr $(libDataBase_OBJ) *.o
    @rm -f *.o
    $(shell echo '#pragma once' > $(libDataBase_REP);)
    $(foreach hed,$(libDataBase_HED),$(shell echo '#include "$(hed)"' >> $(libDataBase_REP);))

main    :   FORCE
    $(call setRefVar,libDataBase)
    $(GCC_BIN) $(GCC_FLG) -c $(libDataBase_INC) $(MAIN_SRC)
    $(GCC_BIN) $(GCC_FLG) -o $(MAIN_BIN) $(MAIN_OBJ) $(libDataBase_LIB)
    @rm -f *.o


FORCE   :



define setAllVar
    $(eval $1_HED = $(notdir $(wildcard $1/src/*.hpp)))
    $(eval $1_SRC = $(wildcard $1/src/*.cpp))
    $(eval $1_OBJ = $1/lib/lib$1.a)
    $(eval $1_REP = $1/inc/$1.hpp)
    $(eval $1_INC = -I$1/inc -I$1/src)
    $(eval $1_LIB = -L$1/lib -l$1)
endef

define setRefVar
    $(eval $1_OBJ = $1/lib/lib$1.a)
    $(eval $1_REF = $1/inc/$1.hpp)
    $(eval $1_INC = -I$1/inc -I$1/src)
    $(eval $1_LIB = -L$1/lib -l$1)
endef
  • 適当にデータみたいなクラスを定義する
  • コピー代入演算子void operator=(const claassName& arg)でやった
    • クラス内の演算子オーバーロードでは左辺が自分自身になるので問題はない
    • 引数(のメンバ変数)は変更しないので非変更宣言のconstをつけておく
    • 戻り値をvoidではなくclassName&にすると=を連鎖的に実装できる
      • a=b=cを実装するには構文の都合で常に同じクラスを返さないとダメ
      • でも今までその書き方をしたことがない,混乱の元だと思っているので
  • メンバ関数に非破壊宣言のconstをつけるときは先頭(戻り値)ではなく末尾
    • 戻り値にconstをつける関数に意味があるのかは正直よくわかっていない
    • コピー代入演算子の引数にconstをつけると,引数argを変更しないという宣言になるが,その処理でargの持つ関数を呼び出す場合は,それがarg自身を変更しないことをコード上で保証する必要があるため
  • 比較のためにデフォルトコンストラクタ・コピーコンストラクタ・デストラクタの全てでログを吐かせる
#pragma once
#include <string>


namespace database{

class framework{
    private:
        int ID;
        std::string Name;
        int Year;
    public:
        framework();
        framework(int id);
        framework(const framework &arg);
        ~framework();
        void operator=(const framework &arg);
        void setName(std::string name);
        void setYear(int year);
        int getID() const;
        std::string getName() const;
        int getYear() const;
};

}
  • constに設定したgetXXX()を使用してconstで引数を取れるコピー代入演算子を作成する
#include <iostream>
#include "framework.hpp"

using namespace std;
using namespace database;


framework::framework(){
    cout << "  framework: Default Constructor, ID: " << -1 << endl;
    ID = -1;
    Name = "";
    Year = 0;
}

framework::framework(int id){
    cout << "  framework: Normal Constructor, ID: " << id << endl;
    ID = id;
    Name = "";
    Year = 0;
}

framework::framework(const framework &arg){
    cout << "  framework: Copy Constructor, ID: " << ID << endl;
    ID = arg.getID();
    Name = arg.getName();
    Year = arg.getYear();
}

framework::~framework(){
    cout << "  framework: Destructor, ID: " << ID << endl;
}


void framework::operator=(const framework& arg){
    cout << "  framework: Substitute, ID: " << ID << " -> " << arg.ID << endl;
    ID = arg.getID();
    Name = arg.getName();
    Year = arg.getYear();
}


void framework::setName(string name){
    cout << "    Set Name: " << name << endl;
    Name = name;
}

void framework::setYear(int year){
    cout << "    Set Year: " << year << endl;
    Year = year;
}


int framework::getID() const{
    cout << "    Get ID: " << ID << endl;
    return ID;
}

string framework::getName() const{
    cout << "    Get Name: " << Name << endl;
    return Name;
}

int framework::getYear() const{
    cout << "    Get Year: " << Year << endl;
    return Year;
}
  • 適当にデータベースみたいなクラスを作る
  • <map>を使ってみた,あとイテレータを使用せずforeachを使いたかったので-std=c++11を指定した
  • map自体はintidframework*なデータを持つteamDataに使用
    • addMembereraseMembershowMemberAPIみたいにして実装
  • _id_latestは吐き出したIDの最新の値を指す,ガバガバ実装なのは許して
#pragma once
#include <string>
#include <map>
#include "framework.hpp"


namespace database{

class interface{
    private:
        framework myData;
        std::map<int, framework*> teamData;
        int _id_latest;
    public:
        interface();
        interface(std::string name, int year);
        ~interface();
        int addMember(std::string name, int year);
        void eraseMember(int id);
        void showMember(int id);
};

}
  • mapのデータ構造がよくわからなかったので,ポインタで持つ処理を採用した
    • 比較用に実体で持つオブジェクトも同時に1つ用意した,挙動を確認しよう
  • デストラクタのメモリ解放処理を簡単に書きたいがためだけにc++11を選んだ
    • for (const auto& member: map){}が書けるのはc+11以降…?
    • autoのところはちょっと面倒だったので厳密な書き方をしなかっただけ
#include <iostream>
#include "interface.hpp"

using namespace std;
using namespace database;


interface::interface(){
    cout << "interface: Default Constructor" << endl;
}

interface::interface(string name, int year){
    cout << "interface: Normal Constructor" << endl;
    myData = framework(0);
    myData.setName(name);
    myData.setYear(year);
    _id_latest = 0;
    interface::addMember(name, year);
}

interface::~interface(){
    cout << "interface: Destructor" << endl;
    for (const auto& member: teamData){
        delete (member.second);
    }
    teamData.clear();
}

int interface::addMember(string name, int year){
    cout << "Add Team Member" << endl;
    ++_id_latest;
    teamData[_id_latest] = new framework(_id_latest);
    teamData[_id_latest]->setName(name);
    teamData[_id_latest]->setYear(year);
    return _id_latest;
}

void interface::eraseMember(int id){
    cout << "Erase Team Member" << endl;
    delete (teamData[id]);
    teamData.erase(id);
}

void interface::showMember(int id){
    cout << "Show Team Member" << endl;
    teamData[id]->getID();
    teamData[id]->getName();
    teamData[id]->getYear();
}
  • とても簡単な使用例
    • 本当はキー待ちIOとかファイル出力とかちゃんとやるべき
  • 不敬
#include <iostream>
#include <string>
#include "libDataBase.hpp"

using namespace std;
using namespace database;


int main(){
    cout << "User name and year" << endl;
    string name;
    cin >> name;
    int year;
    cin >> year;
    interface mydatabase = interface(name ,year);
    int Aiko_id = mydatabase.addMember("敬宮愛子", 2001);
    int Hisahito_id = mydatabase.addMember("秋篠宮悠仁", 2006);
    mydatabase.showMember(Aiko_id);
    mydatabase.showMember(Hisahito_id);
    mydatabase.eraseMember(Aiko_id);
    mydatabase.eraseMember(Hisahito_id);
};

実行

% bin/main
User name and year
Soluna_Eureka
2000
  framework: Default Constructor, ID: -1
interface: Normal Constructor
  framework: Normal Constructor, ID: 0
  framework: Substitute, ID: -1 -> 0
    Get ID: 0
    Get Name: 
    Get Year: 0
  framework: Destructor, ID: 0
    Set Name: Soluna_Eureka
    Set Year: 2000
Add Team Member
  framework: Normal Constructor, ID: 1
    Set Name: Soluna_Eureka
    Set Year: 2000
Add Team Member
  framework: Normal Constructor, ID: 2
    Set Name: 敬宮愛子
    Set Year: 2001
Add Team Member
  framework: Normal Constructor, ID: 3
    Set Name: 秋篠宮悠仁
    Set Year: 2006
Show Team Member
    Get ID: 2
    Get Name: 敬宮愛子
    Get Year: 2001
Show Team Member
    Get ID: 3
    Get Name: 秋篠宮悠仁
    Get Year: 2006
Erase Team Member
  framework: Destructor, ID: 2
Erase Team Member
  framework: Destructor, ID: 3
interface: Destructor
  framework: Destructor, ID: 1
  framework: Destructor, ID: 0

見た感じ問題なく動いてるからよし

  framework: Default Constructor, ID: -1
interface: Normal Constructor
  framework: Normal Constructor, ID: 0
  framework: Substitute, ID: -1 -> 0
    Get ID: 0
    Get Name: 
    Get Year: 0
  framework: Destructor, ID: 0
    Set Name: Soluna_Eureka
    Set Year: 2000

まずframeworkのデフォルトコンストラクタが起爆して,次にinterfaceのコンストラクタが起爆する そして新たにframeworkコンストラクタが起爆して,生成されたオブジェクト(右辺値)のコピー代入演算子によるオブジェクト(左辺値としてのメンバ変数)への代入が発生し,最後にデストラクタでオブジェクト(右辺値)が消滅する

Add Team Member
  framework: Normal Constructor, ID: 1
    Set Name: Soluna_Eureka
    Set Year: 2000
Add Team Member
  framework: Normal Constructor, ID: 2
    Set Name: 敬宮愛子
    Set Year: 2001
Add Team Member
  framework: Normal Constructor, ID: 3
    Set Name: 秋篠宮悠仁
    Set Year: 2006

ここではコンストラクタのついでにaddを使ってmapnew frameworkに登録している 愛子様悠仁様,両親王陛下も.わたくしと全く同じ動作をされておりますが,全く同じ関数をご使用賜れておられるゆえ,当然のことだろうと存じ上げます(番号の順番はスルーしてくれ)

Erase Team Member
  framework: Destructor, ID: 2
Erase Team Member
  framework: Destructor, ID: 3
interface: Destructor
  framework: Destructor, ID: 1
  framework: Destructor, ID: 0

そしてeraseもデストラクタもちゃんと動いている,撃ち漏らしはないように思える

感想

<map><string>を初めて使った,しかしmapはともかくstringは日本語をそのまま使えてしまっているが,文字コード的に本当にコレで良いのかどうか疑問,ぶっちゃけまだ解決しきった問題でもなさそうだけど あとポインタで持った方がコピーコスト少ないし拡張性あるし結局はポインタを使う方が良いという結論に至った

自作の行列クラスについて

作ってみたけどコピー代入演算子の引数は参照渡しが使えなかった,だって

z = A*x + B*y

とかやる場合だと,operator*operator+の返り値は行列クラスそのものになるし,これをそのまんまoperator=で受け取るには値渡ししかない 参照渡しと値渡しそれぞれのコピー代入演算子は,クラスで実装できてもコードで呼び出すときは曖昧さでエラーを吐くので,ユーザーは使い分けもできない 引数のconstの厳密な管理も辛い,しかしこっちはちゃんとやるべきだった,マシなコーディング規約を考えて修正したいとは思ってる