C
C++
オブジェクト指向
設計
oop

C++でクリーンなコードの書き方


<注意> まだ少しだけ執筆中です!

/追記予定のもの/

explicit

final

デフォルトコンストラクタの明示的指定について

スマートポインタ(さわりだけ)

ODR系のなんか(ファイル分割とか)

デフォルト引数でコンストラクタに引数を渡さないようにするのは良くない


導入

私が持てる全ての知識を共有するために、このような記事を書くことを決めました。

前半はC++のお作法。

後半はオブジェクト指向の普遍的な設計方法についてです。

なお、C++11以降を前提として話を進めていきます。


謝辞

私に今回の記事を書くにあたってアドバイスを提供してくれたツイッターの方々、

@kazatsuyu

@yumetodo

@coord-e

@badadder

@Gaccho

に感謝の意を表します。


using namespace stdを使うな

以下のコードは典型的な悪いコードです。

#include <iostream>


using namespace std;

int main()
{
cout << "hello" << endl;
return 0;
}

このようにすべきでしょう。

#include <iostream>


int main()
{
std::cout << "hello" << std::endl;
return 0;
}

stdは冗長なのでusing namespace stdで省略したくなるかもしれませんが、してはいけません。

理由は、グローバルな名前空間を汚すからです。

特にstdは膨大で、かなりの確率で自分が定義した何かの名前と衝突しますのでこれは深刻です。

#include <utility>


using namespace std;

struct pair{
};

int main()
{
pair a;
return 0;
}

このコードは正しく見えますがコンパイルエラーです。

理由はpairという構造体はすでにutilityヘッダファイルのstd名前空間内で定義されていて衝突するからです。

もし、あなたが長い、もしくは深いnamespace内にあるものを利用しようとしたければ、苦痛を伴うかもしれません。

namespace hogefugafoobar {

int plus(int a,int b){
return a + b;
}

}

namespace a {
namespace b {
namespace c {
int minus(int a, int b) {
return a - b;
}
} } }

int main()
{
hogefugafoobar::plus(4, 5);
a::b::c::minus(10, 5);
return 0;
}

こういう時はusing namespace したくなりますが、より良い選択肢があります。

それはnamespaceの別名定義です。

namespace hogefugafoobar {

int plus(int a,int b){
return a + b;
}

}

namespace a {
namespace b {
namespace c {
int minus(int a, int b) {
return a - b;
}
} } }

// namespaceのエイリアス設定
namespace hoge = hogefugafoobar;
namespace abc = a::b::c;

int main()
{
hoge::plus(4, 5);
abc::minus(10, 5);
return 0;
}

さて、ここまで名前空間をusing namespaceで省略するなと言ってきましたが、毎回毎回std::coutなどと打つのは嫌になるかもしれません。名前空間エイリアスを使っても、s::coutが限界です。

そこで、局所的に名前空間を省略する方法があります。

#include <iostream>


using std::cout;
using std::endl;

int main()
{
cout << "hello" << endl;
return 0;
}

このように、usingを使うと限定的に名前空間を省略できます。

これならば、std名前空間の全てのアイテムを省略するより影響が少なくて良いです。

(ただし、それでも名前空間を汚すことには変わりないのでヘッダファイルでの使用は禁物)


自作コードは名前空間で包め

先ほど、グローバルな名前空間を汚染しないためにもnamespaceを省略してはいけないと言いましたが、それだけでは不十分です。

自分が書いたコードもグローバルな名前空間に直接定義するのを止めましょう。

例えば、二つの整数の組を扱うクラスを定義してみましょう。

#include <iostream>


namespace my_lib {
class Pair {

int a, b;

public:

Pair(int a, int b) :a(a), b(b) {}

void show() {
std::cout << "{" << a << "," << b << "}" << std::endl;
}

int sum() {
return a + b;
}
};
}

int main()
{
auto pair = my_lib::Pair(4, 6);
pair.show();
std::cout << pair.sum() << std::endl;
return 0;
}

この例ではmy_libという独自の名前空間で自分のコードを包んでいます。


cの標準ライブラリをインクルードするな

基本的にはCの標準ライブラリを使うのはお勧め出来ません。

大抵の場合、より良いライブラリがC++の標準ライブラリとして提供されています。

例えば、printfやscanfよりもstd::cout,std::cinを使うべきです。

(ちなみにcoutはcharacter output。cinはcharacter inputの略のようです)

しかし、人はCの標準ライブラリを使わざるを得ない場面もあります。

ですが、#include <stdio.h>等を使用するのは良くないです。

Cの標準ライブラリをC++用に修正した#include <cstdlib>を使用しましょう。

#include <cstdio>


int main()
{
std::printf("hello world! \n");
return 0;
}

これの利点は、stdを付けて呼べる所です。

こうすることで標準ライブラリと自分の定義したものとの差別化をすることもできます。

残念ながらC++対応版C標準ライブラリの定義はstdを付けて呼べますが、ほとんどは付けなくても呼べてしまいます。

つまり、グローバル名前空間を汚染してしまいます。妥協しましょう。

cstdioの他にも、cstdlib、cmath、cstringなどがあります。


uniform initializationを使え

uniform initialization(一様初期化)はコンストラクタ呼び出しを()ではなく、{}で行う文法のことです。

コンストラクタ呼び出しはこれを使いましょう。使うことを進める理由と()呼び出しとの違いを説明します。

と、その前にC++の変数初期化についておさらいします。

    int a;

int b(5);
int c=6;
int d = int(8);
int e = int();

すごいですね、int型の変数の初期化だけでこんなにもバリエーションがあります。

上から順に説明しますと、

int a;

は初期値不定の変数。

int b(5);

はデフォルトコンストラクタで5に初期化した変数

int c=6;

はデフォルトコンストラクタで6に初期化された変数

int d = int(8);

はデフォルトコンストラクタで8に初期化された値をさらにデフォルトコンストラクタに渡して初期化された変数

int e = int();

は引数なしデフォルトコンストラクタで0に初期化された変数

次に、頭のいい人は応用でこんなコードを思いつくかもしれません。

    int f();

int g(int());
int h(int(24));

さっきの法則を適応しますと、

int f()

は引数なしデフォルトコンストラクタで0に初期化された変数

int g(int())

は引数なしデフォルトコンストラクタで0に初期化された値をさらにデフォルトコンストラクタに渡して初期化された変数

int h(int(24))

はデフォルトコンストラクタで24に初期化された値をさらにデフォルトコンストラクタに渡して初期化された変数

となりま、、、、、せん。

fとgは関数のプロトタイプ宣言で、hだけがint型の変数宣言になります。

(fは引数なしでint型を返す関数。gは関数ポインタint(*)を受け取りintを返す関数。と解釈されてしまう)

このようにc++ではコンストラクタ呼び出しとプロトタイプ宣言の文法が曖昧になり、問題になることがあります。

これを解決するために、コンストラクタ呼び出しでは()ではなく{}を使用できるようになりました。

    int f{};

int g(int{});
int h(int{24});

この場合はf,g,h共に変数です。

このようにuniform initializationはどんな時でも一様に初期化することができます。

コンストラクタ呼び出しに{}を使うメリットはまだあります。

それは引数の評価順番です。

()で引数を渡すと、どの順番で引数が評価されるかは不明です。

それに比べて、{}で引数を渡した場合は必ず左から順番に評価されることが保証されます。

詳しくはこちらをどうぞ

https://cpprefjp.github.io/lang/cpp11/uniform_initialization.html


staticより無名名前空間を使え

リンケージがわからない方はここら辺を読むといいかもです。

http://www7b.biglobe.ne.jp/~robe/cpphtml/html01/cpp01069.html

C言語では関数を内部リンケージにする時にstatic修飾を使っていました。

しかし、C++なら無名名前空間が便利でしょう。特に大量の関数を内部リンケージ指定したいならかなりお勧めです。

まずは従来のstaticを使ったやり方

static void a() {}

static void b() {}
static void c() {}

無名名前空間を使ったやり方

namespace {

void a() {}
void b() {}
void c() {}
}

無名名前空間のほうがシンプルに内部リンケージを指定できますね。

無名名前空間のさらなる解説はこちらがお勧めです。

http://marycore.jp/prog/cpp/unnamed-namespace/


NULLじゃなくてnullptrを使え

NULLを使うのはもう止めましょう。代わりにnullptrを使いましょう。

NULLは実際にはnullポインタではありません。ただの0です。

#define NULL 0です。

nullptrは本当の意味でのnullポインタです。

nullptr値はnullptr_tという特別な型の値です。

NULLを使うと起きる問題について説明します。

以下のコードがあります。

#include <iostream>


void kind(int) {
std::cout << "int" << std::endl;
}

void kind(void *) {
std::cout << "pointer" << std::endl;
}

int main()
{
kind(NULL);
kind(nullptr);
return 0;
}

実行結果

int

pointer

NULLはnullポインタのはずなのに実際は0なので、kind(int)の方が呼ばれてしまいます。

一方nullptrはちゃんとポインタとして扱われています。

nullptrを使う方が意図もわかりやすく、ミスを防げるのでお勧めです。


int型を使うな

int型のサイズは仕様で厳密には決められていません。

32bitだったり64bit、あるいは16bitの可能性もあります。

同様に他の整数型もサイズは環境によって違います。

詳しくは以下のサイトにまとめられています。

https://en.cppreference.com/w/cpp/language/types

簡単にまとめますと、


最小サイズ

short int
16bit

int
16bit

long int
32bit

long long int
64bit

という風になっており、最小サイズ以上であればどんなサイズでもOKとなってしまっています。

サイズが不定以外の問題点として、型名が冗長すぎるということです。

long long intって笑っちゃうくらい長い名前ですよね。

そしてこの名前からは64bit以上のサイズがあるとは想像できるでしょうか。私はlong longでとても大きいのだろうということぐらいしか想像できませんでした。

そこで、C++ではサイズが明示的なint型が用意されています。

#include <cstdint>

int main()
{
std::int8_t a;//8bit
std::int16_t b;//16bit
std::int32_t c;//32bit
std::int64_t d;//64bit
return 0;
}

これならば、サイズは明確に固定され、型名もシンプルです。

intだけでなく、符号なし整数用のuint<ビット数>_tも存在します。

なお、これらの○○_t型は必ずしも全ての処理系で実装されている保証はないため、注意が必要です。


C言語スタイルのキャストを使うな

C言語のキャストは色々なことができてしまい危険です。

C++では用途に応じて、専用のキャストが用意されています。

専用のキャストを使うことで意図を明示できるので可読性が上がります。

キャストの詳細についてはこちらの記事をどうぞ。

http://kaworu.jpn.org/cpp/%E3%82%AD%E3%83%A3%E3%82%B9%E3%83%88


関数をオーバーライドする時にoverride付けろ

関数のオーバーライドをする時には必ずoverride修飾子を付けてください。これはあなたのことを思ってのことです。

overrideを付けないと不可解な動作に頭を悩ませることでしょう。

まずはoverrideを付けないで関数をオーバーライドする例はこちらになります。

#include <iostream>


class HogeBase {
public:
virtual void fuga() {
std::cout << "HogeBase" << std::endl;
}
};

class Hoge:public HogeBase {
public:
void fuga() {
std::cout << "Hoge" << std::endl;
}
};

int main()
{
Hoge h;
HogeBase* hp = &h;
hp->fuga();
return 0;
}

HogeBaseのfugaはHogeによって問題なくオーバーライドされ、画面にはHogeが表示されることでしょう。

しかし、このコードはあまり良くありません。

例えばもしvirtualを付け忘れたらどうでしょうか?

以下がそのコードです。

class HogeBase {

public:
//virtual付け忘れ
void fuga() {
std::cout << "HogeBase" << std::endl;
}
};

class Hoge:public HogeBase {
public:
void fuga() {
std::cout << "Hoge" << std::endl;
}
};

残念ながらオーバーライドは行われず、HogeBaseが呼ばれることでしょう。

悲しいですね。

次に、何かの拍子で関数の名前が親クラスだけ変わってしまった場合はどうでしょうか。

class HogeBase {

public:
//fugaからfugeへ変更
virtual void fuge() {
std::cout << "HogeBase" << std::endl;
}
};

class Hoge:public HogeBase {
public:
void fuga() {
std::cout << "Hoge" << std::endl;
}
};

またもやオーバーライドは行われないので、HogeBaseが出力されます。

これがもし規模がより大きくなった場合は、ミスに気が付くのはかなり時間がかかるでしょう。

では、明示的に関数をオーバーライドすることを示すoverrideを付けてみましょう。

class HogeBase {

public:
virtual void fuga() {
std::cout << "HogeBase" << std::endl;
}
};

class Hoge:public HogeBase {
public:
//override付ける
void fuga() override {
std::cout << "Hoge" << std::endl;
}
};

これを付けることで、必ずオーバーライドしていることを保証できます。

先ほどの二つのミスは、overrideを付けることで、コンパイルエラーにしてくれます。


defineはなるべく使うな

defineはC言語での古いやり方です。

いまどきのC++ならconstexprを使いましょう。

(もちろん、defineを禁止するわけにはいきません。インクルードガード、プラットフォームごとに使用する関数をコンパイル時に切り替える、ボイラーテンプレートの緩和などではdefineは今だ有効です)

before

//円周率

#define PI 3.14

//度数法を弧度法に変換
#define DigToRad(x) (x*PI/180)

after

//円周率

constexpr double PI = 3.14;

//度数法を弧度法に変換
template<typename T>
constexpr double DigToRad(T x) { return x * PI / 180; }

defineは単なる文字列の置き換えですが、constexprはコンパイル時に計算され展開されます。

意図しない文字列の置き換えが起きやすいdefineですが、constexprを使えば安全に処理できます。


typedefは使うな

C++11以降ではtypedefを使った型定義は古いやり方です。

型エイリアス usingを使いましょう。

usingを使うメリットは可読性とtemplateに対応しているところです。

次の例を見てください

typedef int MyInt;

using MyInt=int;

どちらも、int型をMyIntという別名を定義しています。

これは好みの問題かもしれませんが、usingの方が=を使っていて、MyIntがintで定義されたものなんだな、というのが直感的にわかりやすくて好きです。

この時点では両者に違いはさほどありませんが次はどうでしょうか。

typedef double (*MyFunc)(int, int);

using MyFunc = double (*)(int, int);

明らかにusingを使った方が見やすいかと思います。

もっとも決定的な違いとして、usingは型エイリアスにtemplateが使用できます。これはtypedefではできません。

#include <utility>


template<typename T>
using LeftIntPair = std::pair<int, T>;

//使用例
using IntCharPair = LeftIntPair<char>;

このようにusingはtypedefよりも強力なのでこちらを使うことをお勧めします。


GCCなら-Wall -Wextraでコンパイルしろ

gccでコンパイルする時に-Wall -Wextraオプションを指定することで、最大限の警告を出力してくれるようになるみたいです。

これで開発すれば、自分の足を打ち抜くことは少なくなるでしょう。


noexceptで例外安全を保証しろ

noexceptを使うことで、関数が例外を投げないことを保証することができます。

これを使う利点は、


  • 関数が絶対に例外を投げないことを保証できる(強い例外安全性の保証)

  • 関数が例外を投げないことをコンパイラに伝えることで最適化がしやすくなる。

の二点です。

(C++17ではnoexceptは型レベルで組み込まれておりさらに便利なものとなっています。これを使わない手はないですね!)

noexceptの文法は少し複雑です。それは異なる文脈でnoexceptが使用されるからです。

まずは例外を投げないことを指定するnoexceptについて解説します。

void hoge1(){

/*例外を投げるかもしれない処理*/
}
void hoge2()noexcept(false){
/*例外を投げるかもしれない処理*/
}
void hoge3()noexcept{
/*例外を絶対投げない処理*/
}
void hoge4()noexcept(true){
/*例外を絶対投げない処理*/
}

noexcept(真偽値) が基本の書き方です。

noexcept(true)で例外を投げない。

noexcept(false)で例外を投げる可能性がある。

(真偽値)省略のnoexceptで例外を投げない。

で例外安全を明示できます。

noexceptを省略した場合はデフォルトの例外指定になります。

(デフォルトでは普通の関数はnoexcept(false)扱いです)

注意すべきことは、例外を投げる関数にnoexcept指定をすると、即座にstd::terminate()が呼ばれプログラムが異常終了することです。

#include<iostream>


void hoge() noexcept {
throw "aabb";
}

int main()
{
try{
hoge();
}catch (...) {
std::cout << "error!";
}
return 0;
}

この例では例外をcatchしようとしていますが、例外はcatchされずにプログラムが異常終了します。

これとは別に、式が例外を投げる可能性があるかどうかを判定するnoexcept演算子があります。

#include<iostream>


void hoge1(){
/*例外を投げるかもしれない処理*/
}
void hoge2() noexcept {
/*例外を絶対投げない処理*/
}

int main()
{
//falseが出力される
std::cout << std::boolalpha << noexcept(hoge1()) << std::endl;

//trueが出力される
std::cout << std::boolalpha << noexcept(hoge2()) << std::endl;

return 0;
}

noexcept(式)という書き方で、式が例外を投げる可能性があるかをコンパイル時に判定し、true,falseを返します。

実際には式は実行されないことに注意してください。

このnoexcept演算子とnoexcept例外指定を組み合わせることで、関数内の式が例外を投げるかを判定して、投げないならば関数をnoexcept(true)指定にするといったことも可能です。


マジックナンバーを使うな

マジックナンバーとは意図がよくわからないリテラル値のことです。

リテラル値とは、4とか4.2とか"hello"とかのコードに直接記述する値のことです)

例えば円周率を使って円の面積と円周の長さを求めるプログラムを考えてみましょう。

ここで円周率は3とします。

#include <iostream>


int main()
{
double radius;
std::cin >> radius;
std::cout << radius * radius * 3<<std::endl;
std::cout << radius * 2 * 3 << std::endl;
return 0;
}

このコードの問題点は二つあり、

3が円周率だとは見ただけではわからないこと。

将来的に3が3.14に仕様変更された場合に書き換える場所が2か所あること。

です。

後者の問題に関しては、後ほど説明するDRYにも関係があります。

これを解消するためには定数に名前を付けるのが良いでしょう。

constでもいいですが、今回はコンパイル時に確定している値なのでconstexprを使いましょう。(constexprを使うことで値をその場でインライン展開できる)

#include <iostream>


int main()
{
constexpr int PI = 3;
double radius;
std::cin >> radius;
std::cout << radius * radius * PI<<std::endl;
std::cout << radius * 2 * PI << std::endl;
return 0;
}


オブジェクト指向実例


プリミティブ型を直接使うな

プリミティブ型やCの構造体を直接使わずに、classや(C++での)構造体で包んでから使うのは、オブジェクト指向の第一歩だと思っています。

では実際にそれを体験してみましょう。

問題:

名前、身長、体重を受け取り、

名前とBMIを表示せよ

例:
名前を入力してください
> らいぱん
身長を入力してください(cm)
>190
>体重を入力してください(kg)
>70

らいぱんさんのBMIは19.3906です

ではさっそくコードをオブジェクト指向は考えずに書いてみます。

#include <iostream>

#include <string>

int main()
{
std::string name;
double height;//身長
double weight;//体重
std::cout << "名前を入力してください" << std::endl;
std::cin >> name;
std::cout << "身長を入力してください(cm)" << std::endl;
std::cin >> height;
std::cout << "体重を入力してください(kg)" << std::endl;
std::cin >> weight;
double bmi = weight / (height*height / 10000);
std::cout << name << "さんのBMIは" << bmi << "です" << std::endl;
return 0;
}

これをプリミティブ型をクラスで包み、さらにC言語のモジュール分割にのっとって、複雑な計算部分を関数に切り出してみたいと思います。

#include <iostream>

#include <string>

struct Human {
std::string name;
double height;//身長
double weight;//体重
};

//BMIを計算する関数
double calculate_bmi(Human* human) {
double bmi =
human->weight / (human->height*human->height / 10000);
return bmi;
}

int main()
{
Human human;
std::cout << "名前を入力してください" << std::endl;
std::cin >> human.name;
std::cout << "身長を入力してください(cm)" << std::endl;
std::cin >> human.height;
std::cout << "体重を入力してください(kg)" << std::endl;
std::cin >> human.weight;
std::cout << human.name << "さんのBMIは"
<< calculate_bmi(&human)
<< "です"<<std::endl;
return 0;
}

このように考えなければならないことを関数で分離していくことは、オブジェクト指向でも構造化プログラミングでも変わりません。


データを閉じ込めろ

しかし、これでは、C言語での設計技法です。

まだオブジェクト指向ではありません。

オブジェクト指向ではデータを自分の目の届く範囲に閉じ込めます。

では実際に閉じ込めていきましょう。

#include <iostream>

#include <string>

class Human {
private :
std::string name;
double height;//身長
double weight;//体重
public:

//コンストラクタ
Human(const std::string& name,double& height,double& weight)
:name(name),height(height),weight(weight){}

//名前の取得
std::string& get_name() { return name; }

//BMIを計算する関数
double calculate_bmi() {
double bmi =
weight / (height*height / 10000);
return bmi;
}
};

int main()
{
std::string name;
double height;//身長
double weight;//体重
std::cout << "名前を入力してください" << std::endl;
std::cin >> name;
std::cout << "身長を入力してください(cm)" << std::endl;
std::cin >> height;
std::cout << "体重を入力してください(kg)" << std::endl;
std::cin >> weight;
Human human(name, height, weight);
std::cout << human.get_name() << "さんのBMIは"
<< human.calculate_bmi()
<< "です" << std::endl;
return 0;
}

一番重要な変更点はHumanクラスのメンバ変数を全てprivateにしたことです。

これにより、Human内部でしかデータにアクセスできなくなりました。

さらに、そのままだとcalculate_bmiが計算できないので、Humanクラスのメソッドとして入れてしまいます。

最後に、画面に名前を表示しないといけないので、名前を取得する関数get_nameを作りました。

これにより、name,height,weightは一度値が決まると、二度と値が変わらないことが保証され、コードの可読性が上がるでしょう。


できるだけイミュータブルにしろ

先ほどのコードは大分オブジェクト指向らしくはなりましたが、まだ不十分です。

特に一番問題なのはget_name関数です。

//名前の取得

std::string& get_name() { return name; }

問題はconst修飾なしで返り値を参照で返していることです。

(そもそもなぜ返り値を参照で渡すのでしょうか?

それは、stringのコピーを返すのは遅いからです。)

const修飾なしで参照を返しているということは、好きに値を変更できてしまうということです。

せっかくprivateにしてデータを閉じ込めたのに勝手に書き換えられては全てが台無しですね。

ここはconstを付けて修正しましょう。

//名前の取得

const std::string& get_name() { return name; }

基本的にオブジェクト指向の考え方として、値を書き換える役割を持つものは少なくすべきです。そうしないとあなたは複雑にデータが書き換わるプログラムのトレースをするはめになります。

では次に行きます。

c++にはメンバ変数の値を書き換えない事が保証されるconstな関数を定義できます。

bmiを計算する関数を確認してみましょう。

    //BMIを計算する関数

double calculate_bmi(){
double bmi =
weight / (height*height / 10000);
return bmi;
}

この関数はメンバ変数を一切書き換えてないのでconstな関数にできます。

    //BMIを計算する関数

double calculate_bmi() const {
double bmi =
weight / (height*height / 10000);
return bmi;
}

このconst修飾を付けることで、値が書き換わらないことが瞬時にわかり、またコンパイラの最適化のヒントにもなります。

同様にget_name関数もconst修飾できます。


命名規則を統一しろ

オブジェクト指向とは直接関係はしませんが、命名規則を統一するのは可読性のために重要です。

例えば今回では、

クラス名はアッパーキャメル、

変数はスネークケース、

関数名はスネークケース、

定数は全部大文字、

として命名しました。

他にもクラス名は名詞を使い、関数名は動詞で命名するのが良いとされています。

また英語で書くように統一すると良いでしょう。

(もちろんこれはあくまで一例であり、プロジェクトごとに命名規則は違います)


最長不倒関数を書かない

一つの関数の行数が1000行を超える。

そのようなコードはテスタビリティ、保守性、可読性を著しく低下させるので、適切に関数を分割しましょう。


DRY原則

DRYとはDon’t Repeat Yourselfの略です。

簡単に言えば同じような処理を何回も書くなという感じです。

似たような処理を2回以上記述していたら、その処理は関数に切り出して処理を一か所にまとめれないか努力してください。

基本的には不可能ではないはずです。


単一責任法則

これは役割をクラスでどれぐらいの粒度で分割するのかについての決めごとです。

設計の考え方である、関心の分離に深く関わってきますので重要です。

単一責任法則とはずばり、「ある一つのクラスが変更される理由は一つだけにしろ」ということを言っています。

その結果、クラス一つが持つ責任は一つだけになります。

これにより、クラスは適切に分割されるので可読性が上がり、さらに変更箇所が一か所に集中するので保守性も良くなるでしょう。

さて、「ある一つのクラスが変更される理由は一つだけにしろ」というのが単一責任法則なのですが、この説明は難しいので言い換えましょう。

クラスはただ一つの意味のあるまとまりのデータ群を持っていて、そのデータ群を管理する責任だけを持つ。

ここで注意すべきは、クラスが持つデータ群の管理責任を他のクラスに分散しないことです。

(分散はだめだが、譲渡は問題ない。この譲渡する考え方をムーブセマンティクスという。C++にもムーブコンストラクタという機能でサポートされている)

さらに管理責任で最も大事なのは書き換え責任です。

できるだけイミュータブルにしろの項目のget_name関数で外部からnameが書き換えられるのを防止したのも、書き換え責任を分散しないためです。

(書き換え責任という点において、HaskellやRust等は究極の単一責任法則を実現できるでしょう。具体的に、Haskellでは副作用を分離し、値はそもそも書き換えられない。Rustでは所有権を持つものだけが値を書き換えられる。でそれぞれ書き換え責任を言語レベルで制御しています)


明確な名前を付けろ

明確な名前を付けることには二つの意味があります。

意図が分かりやすくなり可読性が向上することと、

クラスの責任を明確にして、神クラスの誕生を防止することです。

(神クラスは大量の関数や変数が定義された超巨大万能クラスのこと。もちろん単一責任法則を破る)


略さない

明らかに慣習的なもの以外は命名を略してはいけません。

gameObject → go

parent → pt

book → b

student_list → s_list

あなたはこれでわかるかも知れませんが周りは迷惑です。

略さないでしっかりと書きましょう。

(Haskellなどの一部の言語では積極的に変数名を略すケースはありますが、代わりに型名はしっかり書いているのであまり問題にはなりません。

このように変数の意味があまりにも自明な場合は変数名だけ略すことはあります。)


責任を明確になるように

ゲームプログラミングで例えますが、GameControllerとかGameMasterとかのクラス名は危険信号です。Gameを管理するという意味に捉えた場合、Gameを管理するとはいったいどこまでの範囲をそのクラスが担当するか責任が曖昧になってしまいます。

結局、このような名前のクラスには、何でもかんでも役割を持たせてしまい(神クラスですね)、オブジェクト指向のメリットが薄くなってしまいます。

必ずクラス一つが持つ責任を小さくなるように明確な命名を心がけましょう。

ゲームの進行を管理するならGameSequence等、より限定した名前にした方が良いでしょう。


継承は軽率に使うな、コンポジションできるか検討しろ

まずコンポジションについて説明します。コンポジションとはhas-a関係とも言われるものであり、特定の機能を持つクラスをさらに他のクラスがメンバ変数として所有することで機能を拡張していく手法です。

一方、継承はis-a関係と呼ばれるもので、クラスに外部の機能を取り付けて拡張する手法です。

イメージ的に、コンポジションが内部に閉じた拡張、継承が外部に開いた拡張という感じがしますがどうなんでしょう?(わからん)

一般に、継承を使用した拡張は複雑になりやすく、可読性が落ちる可能性があるのでできることならばコンポジションの使用をお勧めします。

常に所有関係(has-a)にできないか検討しましょう。

継承を使うときはポリモーフィズムの使用や既存ライブラリの拡張くらいにとどめておくのがよさそうです。

(ちなみに継承で親クラスのメンバ変数をprotectedにするのはかなりの犯罪だと思う。カプセル化が壊れます)


最後に

C++でオブジェクト指向とは、継承、カプセル化、ポリモーフィズムなどとよく言われます。

しかしこれはオブジェクト指向ができる表面上の操作を言っているに過ぎず、オブジェクト指向の本質を理解したとは言えません。

まずはオブジェクト指向が生まれる理由となった、構造化プログラミング、モジュール分割、分割技法などについて学ぶ必要があるでしょう。

オブジェクト指向とはこれら責任を分割する技法をさらに抽象化して簡単にした技法なのです。

これらを踏まえたうえでSOLID原則について学ぶと良いと個人的に思います。

(さらに発展的なものとしてクリーンアーキテクチャなどがある)

ここまで、長い記事を読んでいただきありがとうございました。


(オマケ)C++を辞めてRustにしろ

C++は人間には到底理解できる言語ではありません。

これは本来、神が使用することを想定された言語だからです。

代わりにRustを使いましょう。

私が書いた記事でも載せときますね。

https://qiita.com/elipmoc101/items/3c8b6d8332a9019e578c

https://qiita.com/elipmoc101/items/f76a47385b2669ec6db3


参考

namespaceの賢い使い方

typedef は C++11 ではオワコン

キャストの解説

基本型の情報

uniform initialization

noexcept

メッセージ指向、クラス指向、インターフェイス指向

SOLID原則まとめ