🎮

C++ライブラリ化のすゝめ

2023/12/05に公開

「神戸電子専門学校 ゲーム技研部 Advent Calendar 2023」5日目の記事です。

https://qiita.com/advent-calendar/2023/kdgamegiken

はじめに

C++で何か開発する際、再利用できる関数やクラスを作ることがあると思います。
しかし、それをそれぞれの利用するクラス(ソースファイル)内で別々に定義すると保守性が著しく低下しますし、抽出して別クラスにまとめようとしてもきちんと整理しないでUtility.h/cppに一括でまとめていると神Excelならぬ神Utilityが出来上がります。

かつての私

これを回避するには、その処理の種類ごとにソースファイルを分け、またそれぞれのプロジェクトでも共通する処理を書かないように、外部ライブラリ化する必要があります。今回はこの抽出できる汎用性のある処理を保守性を保持する目的で外部ライブラリ化する方法を紹介します。

ライブラリとは

皆さんの開発では何かしらライブラリを導入していると思います。Windowsに標準で入っているライブラリの他にも、ネット上に公開されている外部ライブラリも存在します。C++にもパッケージマネージャーがいくつか存在していて、vcpkgやconan、VisualStudioならNuGetなどがあり簡単に外部ライブラリを導入できます。それではC++のライブラリとは具体的に何を指すでしょうか?代表的なものはStandard LibraryやStandard Template Library(STL)ですね。他にもゲーム開発用ならDirectXやDirectXTKなども当てはまります。

そしてライブラリにも大きく分けて3つの種類があります。

  • Header Only Library
    ヘッダー(*.h)しかないライブラリ。インクルードするだけで使える。
  • Static/Dynamic Link Library
    ライブラリをコンパイルし、できた成果物(Windowsなら*.libや*.dll)をリンク(Visual Studioならプロジェクト設定でリンクできる)することで使える。
  • Source Code(*.cppが含まれていることを指す)
    ビルド(Visual Studioならプロジェクト)にそのソースコードを含めて利用する。

があります。
ライブラリ化する上で利用する種類は、手軽に作成できるHeader Only Libraryを利用します。

Header Only Libraryとは

前述したとおり、ヘッダーのみで完結するライブラリです。例として、Jsonのライブラリがこれに当たります。
メリットとしては、作成しやすい、導入しやすい、共有しやすい点が挙げられます。
デメリットとしては、ビルド時間の増加、ビルドサイズの増加などが挙げられます。

デメリットも挙げましたが、ソースコードが数万行といったことでない限りそこまで神経質になる必要はないと思います。今回は比較的作成が容易なHeader Only Libraryを作成していきたいと思います。

作成する際のルール

Header Only Libraryを作成するうえでいくつかのルールが存在します。

まず、インクルードガードが必須です。といっても、主要なコンパイラは#pragma onceをサポートしているのでこのように先頭に記述するだけでOKです。

#pragma once

// 以下ヘッダーのインクルードや処理

ただし、#pragma onceはC/C++の標準ではないので、プラットフォーム非依存を意識したソースコードを書くなら昔からあるマクロを利用したインクルードガードを使用しましょう。

#ifndef UTILITY_H_
#define UTILITY_H_

// 以下ヘッダーのインクルードや処理

#endif //!UTILITY_H_

また、クラス/構造体以外で関数を書くにはinline指定子もしくはstatic指定子が必須です。

void NormalFunction() {}
inline void InlineFunction() {}
static void StaticFunction() {}

例えば、ヘッダー内にこのような定義があるとします。これを複数の翻訳単位でインクルードすると、

1>submain.obj : error LNK2005: "void __cdecl NormalFunction(void)" (?NormalFunction@@YAXXZ) は既に main.obj で定義されています。
1>testapp.exe : fatal error LNK1169: 1 つ以上の複数回定義されているシンボルが見つかりました。

というNormalFunctionの部分でエラーが出ます。これはどういうことかというと、ヘッダーに定義を書いているため、それぞれの翻訳単位で定義が作成されてしまい、ODR違反でエラーが出ています。これを回避するにはinline指定子もしくはstatic指定子が必要です。

そしてどちらを利用する方がいいかという問題ですが、inline指定子の方が良いです。

この画像は同じ翻訳単位の上がinline指定子の関数、下がstatic指定子の関数です。右のアドレスは関数のアドレスを表しています。画像の通り、inline指定子の方は同じアドレスを指していますが、static指定子の方は違うアドレスを指しています。つまりstatic指定子の関数の場合、翻訳単位が増えるほど同じ処理の関数が生成されていきます。特定の条件によっては必要かもしれませんが、普段は必要ないので普通はinline指定子の関数を定義します。

最後に必須ではありませんが、コメントは書いておきましょう。せっかく外部ライブラリ化したのに、その関数が何をするものなのかわからずソースを見に行くのは非効率なので書いておきます。
コメントの書き方にもいくつかルールがありますが、よく見かけるのはXMLスタイルのコメントとDoxygenスタイルのコメントです。
XMLスタイルのコメントは、Visual Studioを使っている人ならなじみ深いのではないかと思います。

書き方は簡単で、クラスや関数の1行上で'///'と入力するとフォーマットが自動生成されます。

関数の説明や引数の説明をタグの中に書いていきます。

使う所でマウスオーバーしてみると、書いた説明が表示されるようになります。

ちなみにDoxygenスタイルのコメントはこのようなフォーマットになっています。

GitHub Copilot Chatを使ったコメントの生成

コメントに関しても、GitHub Copilot Chatを使うと関数を解析していい感じのコメントを生成してくれます。

実際に作ってみる

今回はVisual StudioのOutput機能を使ったUnityのDebug.Log/Warning/Errorに相当する機能を作ってみたいと思います。source_locationを利用しているので、C++20以上が必要になります。

#pragma once

#include <crtdbg.h>
#include <source_location>
#include <stdexcept>
#include <string_view>

/**
 * @namespace assert
 * @brief Namespace containing functions for displaying messages in visual studio output only in debug builds and also for throwing exceptions.
 */
namespace assert {

    /**
     * @brief Show an info message.
     * @param message Message to display in the visual studio output.
     * @param location Source location.
     */
    void ShowInfo([[maybe_unused]] std::string_view message, [[maybe_unused]] const std::source_location& location = std::source_location::current()) {
#ifdef _DEBUG
        _CrtDbgReport(_CRT_WARN, location.file_name(), location.line(), NULL, "info: %s\n", message.data());
#endif // _DEBUG
    }

    /**
     * @brief Show a warning message.
     * @param message Message to display in the visual studio output.
     * @param location Source location.
     */
    void ShowWarning([[maybe_unused]] std::string_view message, [[maybe_unused]] const std::source_location& location = std::source_location::current()) {
#ifdef _DEBUG
        _CrtDbgReport(_CRT_WARN, location.file_name(), location.line(), NULL, "warning: %s\n", message.data());
#endif // _DEBUG
    }

    /**
     * @brief Show an error message.
     * @param message Message to display in the visual studio output.
     * @param location Source location.
     */
    void ShowError([[maybe_unused]] std::string_view message, [[maybe_unused]] const std::source_location& location = std::source_location::current()) {
#ifdef _DEBUG
        _CrtDbgReport(_CRT_ERROR, location.file_name(), location.line(), NULL, "error: %s\n", message.data());
#endif // _DEBUG
    }

    /**
     * @brief Throws a runtime error with the specified message.
     * @param message The message to include in the exception.
     * @throw std::runtime_error The exception that is thrown.
     */
    inline void ExceptionThrow(std::string_view message) {
        throw std::runtime_error(message.data());
    }

}

前述したルールの通りにコードを書いていき、関数は名前空間内に記述しています。これで利用する時は、

#include "assert.h"

int main()
{
  assert::ShowInfo("デバッグ情報");

  return 0;
}

とするだけで利用できます。

おわりに

2023/12/05 誤字の修正
2023/12/06 ソースコードで警告が出ていた箇所を抑制するように修正

https://github.com/shirokuma1101/game-libraries

最近はメンテナンスしていませんが、私も専門学生の時の開発で利用した外部ライブラリを公開しています。そのまま利用せずとも、ソースコードを見てなにかの勉強になれば、、、と思います。

神戸電子専門学校ゲーム技術研究部

Discussion