最近の自分のC++スタイル

何だかんだでC++を書き続けています。今やC++20です。C++0xで騒いでいた頃が懐かしいです。

今回は最近自分がC++を書く時に気にしていることについて簡単に紹介していこうと思います。

基本方針

同じコードを触る開発者が多いため、実装者にとっての利便性よりも共同開発者にとっての可読性や保全性を重視しています。そのため、可読性を下げる機能や扱いが難しい機能は扱わない事が多いです。

auto

autoは書き手にとっては便利ですが、多用すると(特に共同開発者にとっての)コードの可読性が下がるためあまり使いません。

// Preferred: articlesの型がひと目で分かる
std::optional<std::vector<CrawledArticle>> articles = CrawlArticles(...);

// CrawlArticles()のシグネチャーを調べない限りarticlesの型が分からない
auto articles = CrawlArticles();

ただし、コンテナのイテレーターは型名が無駄に長く、また変数の型が周辺のコードから明らかなことが多いためautoを使っています。 同様の理由で、std::make_uniqueの戻り値を受ける変数に対してもautoを使っています。

// Preferred: std::make_uniqueの戻り値の型は自明なのでautoを使っても可読性は失われない
auto crawler = std::make_unique<ArticleCrawler>(...);

// autoを使わないとArticleCrawlerが二度出てくるため冗長に見える
std::unique_ptr<ArticleCrawler> crawler = std::make_unique<ArticleCrawler>(...);

std::pairとstd::tuple

operator<などが定義されているため便利ですが、first、secondやget<3>はdescriptiveな名前ではなく、可読性が下がるため極力使わないようにしています。可能であればstructを定義した方が可読性が高いコードになります。

// secondが何を保持する変数なのかひと目で分からない
if (ProcessArticle(article_info.second)) {...}

// (0オリジンで)2番目の要素が何か調べないとコードを把握できない
if (ProcessArticle(std::get<2>(article_info))) {...}

継承

ポリモーフィズムのための継承はほぼ使っていません。たまにStrategyパターンのために使うことがある程度ですが、多用すると他の開発者にとっての可読性が下がる(オブジェクトがインスタンス化されている箇所まで辿らないと仮想関数呼び出しがどの実装を呼んでいるのか分からなくなる)ため気をつけています。Template Methodパターン等、実装を共有する目的での継承は使っていません(共同開発者で使っている人はいます)。

Dependency Injectionでユニットテストをしやすくする目的での継承は使っています。Abstract Factoryも稀に使います。ただし、具象クラスを使ったテストで済む場合(ネットワークやディスクアクセスを起こさず、動作も十分速いクラスに依存するコードの場合)はなるべくDependency Injectionを使わずにテストしています。

テンプレート

テンプレートは強力ですが、共同開発者がコードを読む時に関数テンプレートやクラステンプレートがインスタンス化されている箇所まで辿らないと動作の確認ができない場合があるため多用しないようにしています。ただし、他の人も色々な型の変数で使いたくなるようなライブラリを設計する場合は話が変わってくると思います。

std::unique_ptr

非常によく使います。生のポインタをnewすることはほぼありません。std::unique_ptrをインスタンス化する時はstd::make_uniqueで行います。std::unique_ptrのコンストラクタはほぼ使いません。

std::unique_ptrは関数のシグネチャー上でオブジェクトの所有権の移動を明確化するためにも使えます。可読性や保全性の向上が期待できます。

class Notifier {
 public:
  // 引数がstd::unique_ptrの場合は呼び出し元がオブジェクトの所有権を譲渡することを意味する
  explicit Notifier(std::unique_ptr<MailSender> mail_sender)
    : mail_sender_(std::move(mail_sender)) {}
  ...
 private:
  std::unique_ptr<MailSender> mail_sender_;
};
...
auto mail_sender = std::make_unique<MailSender>(...);
...
// std::unique_ptrの指すオブジェクトの所有権をstd::moveで譲渡する
auto notifier = std::make_unique<Notifier>(std:move(mail_sender));
// 戻り値がstd::unique_ptrのため、呼び出し元が戻り値の所有権を受け取ることを意味する
std::unique_ptr<ArticleCrawler> CreateArticleCrawler();

ちなみにstd::shared_ptrは今のところ滅多に使っていません。

Designated initializers

structの初期化で便利かつ可読性も向上するのでよく使います。日本語では指示付き初期化と言うらしいです。

struct Person {
  std::string name;
  int age = 0;
};

Person person = {.name = "John Doe", .age = 42};

指示付き初期化は元々C99で導入された機能ですが、C++20でも利用可能になりました。ただしいくつかC++20特有の制約があります(こちらのページの"仕様"を参照してください)。

他にも、指示付き初期化は引数が多いorデフォルト引数の一部だけを変えて呼び出すことが多い関数に使うと便利です。 abseil / Tip of the Week #173: Wrapping Arguments in Option Structsに載っている例を少し簡略化したものを以下に紹介します。

struct PrintDoubleOptions {
  std::string_view prefix = "";
  int precision = 8;
  char thousands_separator = ',';
  char decimal_separator = '.';
  bool scientific = false;
};

void PrintDouble(double value,
                 const PrintDoubleOptions& options = {});

// 変更したいオプションだけ指定する
PrintDouble(5.0, {.prefix = "value=", .scientific = true});

structと指示付き初期化なしだと以下のように扱いづらいコードになります。

void PrintDouble(double value, std::string_view prefix = "", int precision = 8,
                 char thousands_separator = ',', char decimal_separator = '.',
                 bool scientific = false);

// 引数リストの後半にあるものだけ変えたい場合、その引数よりも前のデフォルト引数を全て指定しなければならない
// 同じ型の引数が隣り合っていると順番を間違えてもコンパイルが通ってしまう
//(この場合thousands_separatorとdecimal_separator)
PrintDouble(5.0, "value=", 8, ',', '.', true);

終わりに

他にもプロジェクト独自の規約や自分なりの基準がありますが、最近よく気にしているのはだいたいこの辺りです。