ミドル C++ 面接対策

ミドル C++ Backend 面接質問

性能、所有権、本番運用上のトレードオフを説明する必要がある Backend Developer 向けに、ミドル C++ の面接質問を 15 問厳選しました。

ミドル C++ の AI 面接を始めるクレジットカードは不要です。1回分の無料セッションがあります。
英語での技術面接練習非ネイティブ話者が技術面接の合格を目指して練習できるモードです。

メモリ管理

1スマートポインタにおけるカスタムデリータはどのように機能し、どのような場合にC/C++の相互運用性において有用ですか?

カスタムデリータとは、スマートポインタが所有するリソースを解放する際に、デフォルトの delete 処理の代わりに使用される呼び出し可能なクリーンアップ処理です。fclose、close、curl_easy_cleanup、SSL_free、free、各種ライブラリの破棄用関数など、特定の後処理関数で解放しなければならないリソースを扱う場合、C/C++相互運用の観点で有用です。unique_ptr ではデリータの型が unique_ptr 自体の型の一部となるためサイズに影響を与える可能性があり、ステートレスなデリータは最適化によってサイズゼロにできる一方、関数ポインタや状態を持つデリータはメモリを消費します。shared_ptr ではデリータは制御ブロック(control block)に格納され、最後の強い参照の所有者がリソースを解放した際に実行されます。カスタムデリータを活用することで、C言語のリソースも安全にRAII(Resource Acquisition Is Initialization)の管理下に置くことができます。

using FilePtr = std::unique_ptr<FILE, int(*)(FILE*)>;

FilePtr open_file(const char* path) {
    return FilePtr(std::fopen(path, "r"), &std::fclose);
}

struct CurlDeleter {
    void operator()(CURL* h) const noexcept {
        if (h) curl_easy_cleanup(h);
    }
};

using CurlPtr = std::unique_ptr<CURL, CurlDeleter>;
CurlPtr make_curl() {
    return CurlPtr(curl_easy_init());
}
AI コーチを使ってこの質問に答えてみる

2weak_ptr と shared_ptr の制御ブロック(control block)の仕組みについて、循環参照、enable_shared_from_this、および参照カウントのコストを含めて説明してください。

shared_ptr は、強参照カウントと弱参照カウント、およびデリータやアロケータなどの解放処理情報を含む制御ブロックを通じて共有所有権を管理します。shared_ptr オブジェクトのコピーや破棄を行うと、通常はアトミック操作によって強参照カウントが増減するため、異なるスレッド間で別々の shared_ptr オブジェクトを安全に操作できますが、参照カウントの更新ごとにコストが発生します。強参照カウントがゼロになると管理対象のオブジェクトは破棄されますが、制御ブロックは弱参照カウントもゼロになるまで保持されます。weak_ptr はオブジェクトの寿命を延ばすことなく同じ制御ブロックを参照します。lock() を呼び出すと、オブジェクトが生存していれば shared_ptr を返し、破棄されていれば空の shared_ptr を返します。shared_ptr 同士のみで循環参照を形成すると強参照カウントがゼロにならずメモリリークが発生するため、親へのポインタ(back-pointer)やオブザーバーの参照には weak_ptr が使用されます。enable_shared_from_this を使用すると、すでに shared_ptr によって所有されているオブジェクトが既存の制御ブロックを再利用して自身への新しい shared_ptr を作成できるため、二重に制御ブロックが生成される危険を防ぐことができます。参照カウントのコストとしては、アトミックな増減操作、キャッシュの競合、制御ブロック自体の割り当て、および性能上重要なコードパスにおけるオーバーヘッドが挙げられます。

struct Parent;

struct Child {
    std::weak_ptr<Parent> parent; // non-owning back-reference
};

struct Parent {
    std::vector<std::shared_ptr<Child>> children; // owns children
};

void example() {
    auto p = std::make_shared<Parent>();
    auto c = std::make_shared<Child>();
    c->parent = p;
    p->children.push_back(c);

    if (auto locked = c->parent.lock()) {
        // safe use while Parent is still alive
    }
}
AI コーチを使ってこの質問に答えてみる

テンプレート

3C++ の値カテゴリ(value categories)と完全転送(perfect forwarding)について説明し、これらが効率的なジェネリック API の設計においてなぜ重要なのかを述べてください。

C++ の値カテゴリは式(expression)の性質を分類したものです。lvalue(左辺値)は識別性を持ち、式が評価された後も参照可能です。prvalue(純粋右辺値)は多くの一時オブジェクトや計算結果のような純粋な右辺値であり、xvalue(消滅値)はリソースの再利用が可能な有効期限切れ間近のオブジェクトを表します。完全転送とは、型推論を伴う T&& などの転送参照(forwarding reference)を受け取り、std::forward<T>(arg) を用いて転送することで、呼び出し元の値カテゴリ(lvalue は lvalue のまま、rvalue は rvalue のまま)を維持するテンプレート技術です。これは参照の縮約(reference collapsing)規則によって実現されています。ラッパー、ファクトリ、ディスパッチャ、emplace 系の関数において不要なコピーを回避し、オーバーロード解決やムーブの挙動を正しく保つことができるため、効率的なジェネリックバックエンド API において不可欠です。

#include <iostream>
#include <string>
#include <utility>

void sink(const std::string&) { std::cout << "const ref\n"; }
void sink(std::string&&) { std::cout << "rvalue ref\n"; }

template <class T>
void wrapper(T&& arg) {
    sink(std::forward<T>(arg));
}

int main() {
    std::string s = "hello";
    wrapper(s);
    wrapper(std::string{"x"});
}
AI コーチを使ってこの質問に答えてみる

型システム

4一般的なバックエンドコードにおけるauto、decltype、decltype(auto)、およびテンプレート引数推論の違いを比較してください。

autoは変数に対してテンプレートに類似した型推論を使用します。通常のautoは、宣言時にauto&、const auto&、auto&&のように明示的に指定しない限り、通常は参照や最上位のconstを脱落(drop)させます。decltype(expr)は宣言された型や式の型をより厳密に取得します。括弧なしの識別子式は宣言された型を返し、それ以外のlvalue式はT&、xvalueはT&&、prvalueはTを生成します。decltype(auto)はdecltypeの規則に沿って推論し、参照を保持する必要がある戻り値の型などでよく利用されます。テンプレート引数推論はautoと類似していますが、T、T&、const T&、T&&などの仮引数の形式に依存し、独自の規則を持ちます。波括弧による初期化子はよく見られる違いの一つであり、auto x = {1, 2}はstd::initializer_list<int>と推論されますが、通常のテンプレート仮引数では、仮引数がinitializer_listやその他の適切な型を期待していない限り、単独の波括弧初期化子からTを推論することは一般にできません。

int x = 1;
const int cx = 2;
int& rx = x;

auto a = rx;        // int, copy of x
auto b = cx;        // int, top-level const dropped
const auto& c = cx; // const int&, reference preserved by declaration

a = 10;             // does not change x
AI コーチを使ってこの質問に答えてみる

5異なる種類のメッセージやイベントのモデリングにおいて、`std::variant` と継承ベースのポリモーフィズムを比較してください。

`std::variant` は、固定された閉じた型の集合の中から厳密に 1 つの値を保持する値型であり、一般的に `std::visit` や明示的な型クエリによって処理されます。メッセージ種別の集合があらかじめ決まっており、仮想関数ディスパッチを行わず、オブジェクトごとのヒープ割り当てを抑えて型安全に処理したいプロトコルメッセージやイベントに適しています。一方、継承ベースのポリモーフィズムは、基底クラスと仮想関数を使用して共通インターフェイス経由でディスパッチを行います。派生メッセージ型の集合がオープンで拡張可能である場合や、プラグイン構成、あるいは安定したインターフェイスの背後に隠蔽したい場合に適しています。`variant` は閉じた直和型、局所性、コンパイル時チェックを重視する設計に適しており、継承は拡張性、実行時ポリモーフィズム、インターフェイス中心の設計に適しています。

struct UserCreated { std::string id; };
struct UserDeleted { std::string id; };
struct PasswordChanged { std::string id; };

using Event = std::variant<UserCreated, UserDeleted, PasswordChanged>;

void handle(Event const& e) {
    std::visit([](auto const& msg) {
        using T = std::decay_t<decltype(msg)>;
        if constexpr (std::is_same_v<T, UserCreated>) {
            // handle creation
        } else if constexpr (std::is_same_v<T, UserDeleted>) {
            // handle deletion
        } else if constexpr (std::is_same_v<T, PasswordChanged>) {
            // handle password change
        }
    }, e);
}
AI コーチを使ってこの質問に答えてみる

言語セマンティクス

6C++における未定義動作(UB: Undefined Behavior)とは何ですか。また、バックエンドの本番環境のインシデントではどのように顕在化しますか?

未定義動作(UB: Undefined Behavior)とは、無効な操作が発生した後にC++標準仕様が一切の動作要件を課さない状態のことです。プログラムは正常に動作しているように見えることもあれば、クラッシュ、データの破損、セキュリティ上の脆弱性の発生、または最適化によって予期しない振る舞いに変化することもあります。コンパイラはUBが発生しないことを前提として最適化を行うため、リリースビルドや本番環境のトラフィック下でのみ問題が顕在化することがあります。バックエンドのインシデントの原因としては、ダングリングポインタ/参照、解放後使用(use-after-free)、オブジェクトの寿命違反、範囲外アクセス、符号付き整数のオーバーフロー、データ競合、無効なキャスト、未初期化変数の読み取り、二重解放、厳密なエイリアシングルール(strict-aliasing)の違反などが挙げられます。対策としては、RAII(Resource Acquisition Is Initialization)や明確な所有権・寿命設計、安全な抽象化と境界チェック、テストやファジング、コードレビュー、静的解析、そしてASan、UBSan、TSanなどのサニタイザの活用があります。

int grow(int x) {
    if (x + 1 < x) {
        return 0; // compiler may assume this is unreachable
    }
    return x + 1;
}
AI コーチを使ってこの質問に答えてみる

7未定義動作(undefined behavior)、未規定動作(unspecified behavior)、処理系定義の動作(implementation-defined behavior)の違いについて、バックエンドに関連する例を挙げて説明してください。

未定義動作(undefined behavior)は、C++ 標準規格が何ら要件を課さないことを意味し、クラッシュ、データの破損、セキュリティ上の脆弱性、または最適化による誤ったコンパイル結果を引き起こす可能性があります。具体例としては、範囲外アクセス、Use-After-Free(解放後メモリの使用)、符号付き整数のオーバーフロー、データ競合などが挙げられます。未規定動作(unspecified behavior)は、標準規格が複数の結果を許容しており、処理系がどれを選択したかを文書化する必要がない動作を指します。代表的な例に関数の引数の評価順序があり、コードが特定の順序で副作用が発生することに依存すべきではありません。処理系定義の動作(implementation-defined behavior)は、処理系がその動作を選択し、仕様として文書化する必要があるものを意味します。具体例としては、プレーンな `char` の符号の有無、標準の範囲内における基本型のサイズや値の範囲、符号付き整数の右シフトの挙動などが挙げられます。バックエンドコードにおいて、UB(Undefined Behavior: 未定義動作)は正当性やセキュリティ上のリスクであり、未規定動作や処理系定義の動作は、プロトコル、ストレージ、およびクロスプラットフォームのロジックにおいて回避するか隔離すべき移植性のリスクです。

int i = 0;
f(i++, i++); // both increments happen, but the order of argument evaluation is unspecified

// Prefer:
int a = i++;
int b = i++;
f(a, b);
AI コーチを使ってこの質問に答えてみる

エラー処理

8例外安全性のレベルと、noexcept がムーブ操作、コンテナ、およびコード生成に与える影響について説明してください。

例外安全性(exception safety)の各レベルは、ある操作が例外を送出した際にどのような状態が保たれるかを定義します。基本保証(basic guarantee)は、不変条件が維持されリソースリークが発生しないことを意味しますが、状態が変更されている可能性はあります。強い保証(strong guarantee)はコミットまたはロールバックのセマンティクスを意味し、失敗時にも観測可能な状態は変更されません。非送出保証(nothrow guarantee)は、操作が例外を送出しないことを意味します。代表的な手法には、RAII(Resource Acquisition Is Initialization)、一時オブジェクトでの作業、コピー&スワップ(copy-and-swap)、そして例外を送出しうる処理が成功した後にのみコミットが行われるような状態変更順序の制御などがあります。`noexcept` は契約であり、`noexcept` 関数が例外を送出した場合は `std::terminate` が呼び出されます。また、ジェネリックコードやコンテナにも影響を与えます。例えば再割り当て時、`std::vector` はムーブコンストラクタが `noexcept` であれば要素をムーブできますが、そうでなければ例外保証を維持するために、多くの場合 `std::move_if_noexcept` を介してコピーを行います。さらに `noexcept` は、必要な例外伝播パスを削減することでコード生成や最適化に寄与することもありますが、それは指定した約束が正しい場合に限られます。

std::vector<T> v;
// During reallocation, an implementation may effectively do:
T* dest = allocate(new_cap);
for (T& x : old_storage) {
    construct(dest++, std::move_if_noexcept(x));
}
AI コーチを使ってこの質問に答えてみる

9失敗する可能性のある処理において、例外の代替手段としての `std::expected` や同等の outcome 型について説明してください。

`std::expected<T, E>` は、成功時の値 `T` または明示的なエラー `E` のいずれかを表し、例外の伝播に依存する代わりに失敗を関数の戻り値の型の一部にします。これは、パース処理、バリデーション、ネットワーク呼び出し、ストレージ検索、リトライ可能なバックエンド処理など、エラーが想定され呼び出し側で局所的に処理すべき失敗しやすい操作に適しています。エラー型には、エラーコード、カテゴリ、リトライ可否、メッセージ、HTTP/RPCマッピングなどの構造化された情報を持たせるべきです。expected/outcome 型はエラーを検査して伝播させることで合成可能であり、C++23 スタイルの API では `and_then`、`transform`、`or_else` などのモナド操作を利用して、深くネストした条件分岐を作らずに処理ステップを連結できます。例外と比較すると、制御フローと API の契約が明示的になり、例外を使用しない環境や ABI 境界をまたぐ場合でもうまく機能します。プロジェクトの方針によっては、稀にしか発生しない非局所的な障害や、真に例外的な失敗に対しては依然として例外が適切な場合もあります。

enum class Errc { BadRequest, NotFound, Timeout, DbUnavailable };

struct Error {
    Errc code;
    bool retryable;
    std::string message;
};

std::expected<User, Error> load_user(UserId id);

HttpResponse handle(UserId id) {
    auto user = load_user(id);
    if (!user) {
        switch (user.error().code) {
            case Errc::BadRequest: return {400, user.error().message};
            case Errc::NotFound: return {404, "not found"};
            case Errc::Timeout: return {503, "try again"};
            case Errc::DbUnavailable: return {503, "service unavailable"};
        }
    }
    return {200, serialize(*user)};
}
AI コーチを使ってこの質問に答えてみる

オブジェクトの寿命

10自動オブジェクト、動的オブジェクト、一時オブジェクト、および非同期で参照されるオブジェクトにおけるオブジェクトの寿命(ライフタイム)の規則について説明してください。

自動オブジェクトは構築時からそのスコープを抜けるまで生存し、スコープ終了後はそれらを指すポインタや参照はダングリング状態になります。動的オブジェクトはメモリ確保および構築時から、明示的に破棄されるか所有するオブジェクトによって破棄されるまで生存し、生ポインタや参照によって寿命が延びることはありません。一時オブジェクトは通常、完全式(full expression)の終了時まで生存しますが、参照に束縛された場合の例外的な寿命延長ルールも存在します(ただしすべての状況で延長されるわけではありません)。コールバック、スレッド、タイマー、コルーチンなどの非同期処理は参照先オブジェクトが破棄された後に実行される可能性があるため、ダングリング参照や解放後使用(use-after-free)を防ぐには、キャプチャや保持する参照に対して明示的な寿命管理が必要です。

std::function<void()> make_cb() {
    std::string s = "hello";
    return [&] { std::cout << s << '\n'; }; // BAD: captures local by reference
} // s is destroyed here

std::function<void()> safe_cb() {
    auto s = std::make_shared<std::string>("hello");
    return [s] { std::cout << *s << '\n'; }; // OK: callback owns the string
}
AI コーチを使ってこの質問に答えてみる

11静的初期化順序の問題(static initialization order issues)と、constinit、マジックスタティック(magic statics)、および依存性の注入(DI: Dependency Injection)によってそれらがどのように緩和されるかを説明してください。

静的初期化順序の問題は、異なる翻訳単位(translation unit)にある動的に初期化される名前空間スコープや静的オブジェクトの初期化順序が規定されていない(unspecified)ために発生します。あるグローバルオブジェクトが構築される前に別のオブジェクトによって使用される可能性があり、プログラム終了時の破棄順序においても同様の問題が生じます。`constinit` は、静的変数またはスレッドローカル変数が静的/定数初期化されることを強制し、そうでなければプログラムを不正(ill-formed)とすることで、その変数に対する動的初期化順序への依存を排除します。マジックスタティック(関数ローカルの静的変数)は初回使用時に初期化され、C++11以降はスレッドセーフです。依存性の注入(DI)は、制御された順序でオブジェクトを構築し依存関係を明示的に渡すことで、暗黙のグローバル依存を回避します。

// a.cpp
extern std::string configPath;
Logger logger(configPath); // may run before configPath is dynamically initialized

// b.cpp
std::string configPath = readConfigPath();
AI コーチを使ってこの質問に答えてみる

12コピー省略(copy elision)、NRVO (Named Return Value Optimization)、およびRVO (Return Value Optimization) とは何ですか。また、これらにはどのような場合に依存できますか?

コピー省略とは、独立した一時オブジェクトを作成してコピーまたはムーブする代わりに、最終的な配置先にオブジェクトを直接構築することを意味します。RVOは通常、`return T{};`のように名前のない一時オブジェクト(prvalue)を返すことを指します。C++17以降では、prvalueが結果オブジェクトを直接初期化するため、これらのケースの多くが必須(強制)とされています。NRVOは、`return x;`のように名前付きのローカルオブジェクトを返すことであり、コンパイラはそのローカルオブジェクトを呼び出し元の戻り値スロットに直接構築することが許可されていますが、これは保証されていません。規定されたケースにおけるC++17の必須のprvalue省略には依存できますが、NRVOが常に発生することには依存できません。また、名前付きローカル変数の戻り値に対して`std::move`を使用するとNRVOが妨げられる可能性があるため、避けるべきです。

struct T {
    T();
    T(const T&) = delete;
    T(T&&) = delete;
};

T make() {
    return T{}; // OK in C++17+: result object is constructed directly
}
AI コーチを使ってこの質問に答えてみる

標準ライブラリ

13比較関数、等値性、およびハッシュ関数の要件は、連想コンテナの正しさにどのように影響しますか。

連想コンテナは、キーの同一性を定義し、内部の不変条件(インバリアント)を維持するために、比較、等値性、ハッシュ処理に依存しています。std::map などの順序付きコンテナでは、比較関数が厳密な弱順序(strict weak ordering)を満たすことが求められます。キー同士は、必ずしも operator== ではなく、どちらも相手より小さいと判定されない場合に等価と見なされます。順序付けされていない(unordered)コンテナでは、等値判定が同値関係を満たす必要があり、等しいとみなされる任意の2つのキーは同一のハッシュ値を生成しなければなりません。また、格納されているキーの順序、等値性、またはハッシュ値を変化させるような方法でキーを変更してはなりません。そのような変更を行うと、検索、削除、および一意性の挙動が正常に動作しなくなる可能性があります。

struct Key {
    int tenant;
    std::string id;
};

struct KeyEq {
    bool operator()(Key const& a, Key const& b) const {
        return a.tenant == b.tenant && a.id == b.id;
    }
};

struct KeyHash {
    std::size_t operator()(Key const& k) const {
        return std::hash<int>{}(k.tenant) ^
               (std::hash<std::string>{}(k.id) << 1);
    }
};

std::unordered_map<Key, int, KeyHash, KeyEq> m;
AI コーチを使ってこの質問に答えてみる

14順序付きおよび順序なし連想コンテナにおける異種ルックアップ(heterogeneous lookup)について説明してください。

異種ルックアップを使用すると、連想コンテナのキー型とは異なる型を用いて検索できるようになり、一時的なキーオブジェクトの構築を回避できます。順序付きコンテナでは、`std::less<>` や `is_transparent` というタグ型メンバを持つカスタム比較関数のような透過的比較関数(transparent comparator)が必要です。順序なしコンテナでは、キー型と検索対象の型の両方を一貫して処理できる透過的ハッシュ関数と透過的同値比較ファンクタの両方が必要になります。バックエンド開発における代表的な例としては、キーが `std::string` であるコンテナに対して、一時的な `std::string` のメモリ割り当てを発生させずに `std::string_view` や `const char*` で検索するケースが挙げられます。

std::map<std::string, int, std::less<>> counts;
counts.emplace("alpha", 1);

std::string_view key = "alpha";
auto it = counts.find(key); // no temporary std::string required
AI コーチを使ってこの質問に答えてみる

並行処理

15C++ のメモリモデルについて説明してください。データ競合、happens-before 関係、同期保証を含めて説明してください。

C++ のメモリモデルは、異なるスレッド間における操作の順序付けや、書き込みがいつ可視化されるかを定義します。データ競合(data race)は、2つのスレッドが同じメモリ位置に並行してアクセスし、少なくとも一方が書き込みであり、そのアクセスが happens-before 関係で順序付けられていないか、アトミック操作によって安全に保護されていない場合に発生し、未定義動作を引き起こします。happens-before は、先行する副作用を後続の操作から可視にする順序関係です。同期操作はこの順序関係を確立します。たとえば、ミューテックスのロック解除は、同じミューテックスに対する後続の成功したロック操作と同期(synchronizes-with)し、適切なアトミック release/acquire 操作によってスレッド間の同期を取ることができます。正しいプログラムでは、共有状態に対して happens-before を確立するために、ミューテックスやアトミック操作、またはその他の同期機構を使用します。

std::mutex m;
int x = 0;

void producer() {
    std::lock_guard<std::mutex> lk(m);
    x = 42;
} // unlock

void consumer() {
    std::lock_guard<std::mutex> lk(m); // lock after producer unlock synchronizes-with it
    std::cout << x << '\n';
}
AI コーチを使ってこの質問に答えてみる