コンパイラと共に戦う【プログラミング編】

5 min読了の目安(約5200字TECH技術記事

はじめに

バグとの戦いは辛く苦しいもの。
コンパイラと共にバグへ立ち向かっているプログラマの記録です。

今回はプログラミング編。
一番良いデバッグ方法はバグを組み込んでしまったことをコーディング(ビルド)時に気づくこと。
実装エラーをコンパイラに指摘してもらう方法について紹介します。

Clang 9.0.0
C++17
にて動作確認。

型定義

C++はusingにより型の別名を定義することができる。
しかし、これは別名というだけである。
以下のコードは問題なくビルドできてしまう。

#include <iostream>

int main(int, char **)
{
    using Velocity = float;
    using Time = float;

    Velocity v{1};
    Time t{2};
    
    const auto d = v + t;    // !! コーディングミス !!

    std::cout << "v * t = " << d << std::endl;
    return 0;
}

速度と時間を乗算して距離を求めたかったのだが、間違えて加算してしまっている。
こういうつまらないバグはコーディング中のコード補完やコンパイル時に指摘してほしい。

型クラス

そこで、簡易なものではあるが型クラスを定義する。

/// @brief 型クラス
template <typename T, uint32_t UniqueID>
class TypeWrapper final
{
public:
    using Type = T;

public:
    explicit constexpr TypeWrapper(const T& data) noexcept : m_data(data) {}
    inline constexpr T& operator*() noexcept { return m_data; }
    inline constexpr const T& operator*() const noexcept { return m_data; }

private:
    T m_data;
};

// 演算子定義用マクロ
#define TYPEWRAPPER_OPERATOR_DEFINE(RESULT, TYPE1, OPERATOR, TYPE2)              \
    inline RESULT operator OPERATOR(const TYPE1& lhs, const TYPE2& rhs) noexcept \
    {                                                                            \
        return RESULT{*lhs OPERATOR *rhs};                                       \
    }

使い方はこう。

// 型定義
using Velocity = TypeWrapper<float, __LINE__>;
using Time = TypeWrapper<float, __LINE__>;
using Distance = TypeWrapper<float, __LINE__>;

// 演算子定義
TYPEWRAPPER_OPERATOR_DEFINE(Distance, Velocity, *, Time)

int main(int, char **)
{
    Velocity v{1};
    Time t{2};
    
    //const auto d = v + t;    // コンパイルエラー
    const auto d = v * t;

    std::cout << "v * t = " << *d << std::endl;

    return 0;
}

UniqueID(型区別用のID)として__LINE__を指定していますが、別のファイルで同じ行に同じ型が定義されていると同一とみなされます。必要に応じて、UniqueIDをもう一つ増やしファイル名のハッシュをconstexprで計算して指定するなど工夫してみてください。

このようにすると、v + tなどのミスがコンパイルエラーになり安心してコーディングができる。
成果物に不要な演算子を定義しないことによる恩恵もある。

また、関数の引数にすることでもその効果は確認できるだろう。

int main(int, char **)
{
    using HealType = TypeWrapper<int, __LINE__>;
    using DamageType = TypeWrapper<int, __LINE__>;

    class Character final
    {
    public:
        Character() noexcept = default;

        void Heal(const HealType& heal) { (void)heal; }
        void Damage(const DamageType& damage) { (void)damage; }
    };

    Character entity;
    DamageType value{10};

    //entity.Heal(value);     // コンパイルエラー
    entity.Damage(value);

    return 0;
}

たとえ適当な変数名を付けたとしてもダメージ用の値で回復関数が実行されることはない。
他人や、数ヶ月コードから離れていた自分でも安心してメンテナンスすることができる。

関数内で実行できる行動を絞る

別のコンパイル時に気付きたいつまらないバグとして、以下のようなものがある。

// このセクション共通で使うクラス
using DamageType = TypeWrapper<int, __LINE__>;

class Character final
{
public:
    Character() noexcept = default;

    /// @brief ダメージ処理
    void Damage(const DamageType& damage) noexcept { (void)damage; }

    /// @brief 回避処理
    void Dodge() noexcept {}

    /// @brief 描画処理
    void Draw() noexcept {}
};
// 回避実行関数
void DoDodge(Character& character) noexcept
{
    // ...
    character.Draw();   // !! Dodge()と間違えてDraw()を呼んでる !!
    // ...
}

int main(int, char **)
{
    Character character;

    DoDodge(character);

    return 0;
}

DoDodge()はCharacterのDodge()を呼ぶために用意したのだが誤って描画処理Draw()を呼んでしまっている。
エディタでコード補完を利用していれば、全くあり得ない話ではない。

インターフェースによる縛り

関数が行える操作を絞るのは重要である。

class IDodge
{
public:
    virtual void Dodge() noexcept = 0;

protected:
    IDodge() noexcept = default;
    ~IDodge() noexcept = default;
};
void DoDodge(IDodge& target) noexcept
{
    // ...
    //target.Draw();   // コンパイルエラー
    target.Dodge();
    // ...
}

int main(int, char **)
{
    class Dodger final : public IDodge
    {
    public:
        Dodger(Character& character) noexcept : m_character(character) {}

        virtual void Dodge() noexcept override { m_character.Dodge(); }

    private:
        Character& m_character;
    };

    Character character;
    Dodger dodger{character};

    DoDodge(dodger);

    return 0;
}

正しく実装するならば、

class Character final : public IDodge
{
    ...

とCharacterクラスがIDodgeを実装します。

私の場合、一部でしか使わないクラスは関数内や関数外の無名namespaceで定義してしまいます。
バグを減らすという観点では
virtual void Dodge() noexcept override { m_character.Draw(); }
のようなDodge()の実装でDraw()を呼んでしまうバグもありえますが、自分の考える移植性・コードの美しさと天秤にかけた結果このリスクを受け入れています。

これならばDoDodge()内でDraw()を間違えて呼ぶことはない。
さらにDoDodge()はIDodgeを実装したあらゆるクラスを処理することができる。

TypeWrapperによる縛り

また、上記のDodge()に関しては難しいが、引数や戻り値で縛ることができれば前述のTypeWrapperを組み合わせるともう少しスマートに記述することができる。

#include <functional>

/// @brief ダメージ計算関数
using TakeDamageFunc = std::function<void (const DamageType&)>;
void Damage(const TakeDamageFunc& targetFunc) noexcept
{
    DamageType value{0};
    // ... 計算処理
    targetFunc(value);
}

int main(int, char **)
{
    Character character;

    Damage([&](const DamageType& damage) { character.Damage(damage); });

    return 0;
}

ラムダ式を使う場合はキャプチャした変数の寿命に注意したい。
メモリ関連のデバッグ方法に関してはコンパイラと共に戦う【コンパイルオプション編】をご覧いただければとおもう。

おわりに

お察しの通り、オブジェクト指向プログラミングの基礎とされているものがコンパイラと共にバグと戦うための方法だという点について記載させていただきました。

皆様の大切なリソースをデバッグではなくクリエイティブな作業へ少しでも多く向ける手助けになれば幸いです。