C++の開発において、「定数」を適切に扱うことは、コードの安全性、保守性、そして実行パフォーマンスを最大化するために不可欠な要素です。

かつてのC++では定数といえばconst修飾子やマクロ(#define)が主役でしたが、現代のC++(C++11以降、特にC++20以降)では、constexprconstevalといった強力なキーワードが登場し、定数の概念は「単なる読み取り専用の変数」から「コンパイル時に確定する値や計算」へと大きく進化しました。

本記事では、これらのキーワードの具体的な使い方から使い分け、そして現代的な最適解までを徹底的に解説します。

C++における定数の重要性と進化

C++プログラムにおいて定数を利用する最大の目的は、「意図しない値の書き換えを防止する」ことと「コンパイラによる最適化を促進する」ことにあります。

マジックナンバー(プログラム中に直接書かれた数値)を排除し、意味のある名前を付けた定数として定義することで、コードの可読性は飛躍的に向上します。

かつてのC言語スタイルでは、定数の定義に#defineが多く使われていました。

しかし、マクロには型情報がなく、スコープも無視されるため、デバッグの困難さや予期せぬ名前衝突を引き起こす原因となっていました。

現代のC++では、これらをconstconstexprに置き換えることが必須のベストプラクティスとされています。

const修飾子の基本と役割

constは、変数が「読み取り専用(Read-only)」であることを示す修飾子です。

一度初期化されると、その後のプログラム実行中に値を変更しようとするとコンパイルエラーになります。

変数におけるconst

最も一般的な使い方は、ローカル変数やグローバル変数にconstを付与することです。

C++
#include <iostream>
#include <string>

int main() {
    // const修飾子により、値の変更を禁止する
    const int max_retry_count = 5;
    const std::string error_message = "An error has occurred.";

    // max_retry_count = 10; // コンパイルエラー:再代入不可

    std::cout << "Max Retries: " << max_retry_count << std::endl;
    std::cout << "Message: " << error_message << std::endl;

    return 0;
}

ポインタとconstの組み合わせ

ポインタでconstを使用する場合、「指し示す先の内容を固定するのか」それとも「ポインタ自身が指すアドレスを固定するのか」によって記述位置が変わります。

これは初心者が混乱しやすいポイントですが、非常に重要です。

  • const T* ptr:指し示している「中身」が変更不可(定数データへのポインタ)。
  • T* const ptr:ポインタが指す「アドレス」が変更不可(定数ポインタ)。
  • const T* const ptr:中身もアドレスも変更不可。
C++
void pointer_example() {
    int value1 = 10;
    int value2 = 20;

    // 1. 定数データへのポインタ
    const int* p1 = &value1;
    // *p1 = 15; // NG: 中身は変えられない
    p1 = &value2; // OK: 指す場所は変えられる

    // 2. 定数ポインタ
    int* const p2 = &value1;
    *p2 = 15;     // OK: 中身は変えられる
    // p2 = &value2; // NG: 指す場所は変えられない

    // 3. 両方が定数
    const int* const p3 = &value1;
    // *p3 = 15;  // NG
    // p3 = &value2; // NG
}

メンバ関数におけるconst

クラスのメンバ関数にconstを付与すると、その関数内ではオブジェクトのメンバ変数を変更しないことを保証します。

これにより、const指定されたオブジェクトからでもその関数を呼び出せるようになります。

C++
class Circle {
private:
    double radius;
public:
    Circle(double r) : radius(r) {}

    // constメンバ関数:メンバ変数を変更しない
    double get_area() const {
        return 3.14159 * radius * radius;
    }

    void set_radius(double r) {
        radius = r;
    }
};

void process_circle(const Circle& c) {
    // c.set_radius(10.0); // NG: constオブジェクトなので変更メソッドは呼べない
    std::cout << "Area: " << c.get_area() << std::endl; // OK
}

constexpr:コンパイル時定数の導入

C++11で導入されたconstexprは、「コンパイル時に値が確定する」ことを保証するキーワードです。

constが「実行時の読み取り専用」を意味するのに対し、constexprはより強力な制約を持ち、コンパイル時に計算を行う「定数式(Constant Expression)」を定義します。

constexpr変数

constexpr変数は、必ずコンパイル時に評価可能な式で初期化される必要があります。

C++
int main() {
    int runtime_val = 10;
    // const int c1 = runtime_val; // OK: 実行時に決まる読み取り専用値
    // constexpr int c2 = runtime_val; // NG: 実行時の値で初期化できない

    constexpr int compile_time_val = 100 * 2; // OK: コンパイル時に計算可能
    
    // 配列のサイズ指定など、コンパイル時定数が必要な場所に使える
    int arr[compile_time_val]; 
    return 0;
}

constexpr関数

関数にconstexprを付与すると、その関数はコンパイル時に評価される可能性があります。

引数がすべてコンパイル時定数であれば、関数の結果もコンパイル時に算出され、実行時のオーバーヘッドがゼロになります。

C++
// constexpr関数の定義
constexpr int square(int n) {
    return n * n;
}

int main() {
    // 引数が定数なので、コンパイル時に 25 に置き換わる
    constexpr int result = square(5); 

    int x = 10;
    int runtime_result = square(x); // 引数が変数の場合、通常の関数として実行される

    return 0;
}

constexpr関数の利点は、「コンパイル時計算」と「実行時計算」の両方で同じコードを共有できる点にあります。

consteval:即時関数の強制(C++20)

C++20で導入されたconstevalは、さらに一歩踏み込んだ制約を課します。

constevalが付与された関数は「即時関数(Immediate Function)」と呼ばれ、いかなる場合でもコンパイル時に実行されなければならないことを強制します。

constevalの使い所

constexprは「コンパイル時にも実行できる」という意味ですが、constevalは「コンパイル時にしか実行できない(実行時に呼び出すとエラー)」という意味になります。

C++
consteval int force_compile_time(int n) {
    return n * 2;
}

int main() {
    constexpr int a = force_compile_time(10); // OK

    int x = 5;
    // int b = force_compile_time(x); // コンパイルエラー:引数が実行時の値
    
    return 0;
}

このように、constevalを使用することで、パフォーマンス上の理由から実行時に計算を絶対に行いたくないロジックを厳密に管理できます。

constinit:静的初期化の保証(C++20)

constinitはC++20の新機能で、変数の初期化が「静的初期化(コンパイル時またはロード時の初期化)」であることを保証します。

これは主に、グローバル変数の初期化順序問題(Static Initialization Order Fiasco)を防ぐために使用されます。

注意点として、constinitは変数を「定数(const)」にするわけではありません。

あくまで初期化のタイミングを保証するだけで、初期化後は値を変更可能です(constと組み合わせて使うこともできます)。

C++
#include <iostream>

constexpr int get_initial_value() {
    return 42;
}

// 静的初期化を保証(動的初期化=実行時の初期化は禁止)
constinit int global_val = get_initial_value();

int main() {
    // global_valは実行中に変更可能(constではないため)
    global_val = 100;
    std::cout << global_val << std::endl;
    return 0;
}

const・constexpr・constevalの比較と使い分け

これら3つのキーワードの性質を整理すると、以下のようになります。

キーワード評価タイミング主な役割強制力
const実行時(主に)値を読み取り専用にする変更を禁止するのみ
constexprコンパイル時 or 実行時コンパイル時に計算可能にする可能ならコンパイル時に評価
constevalコンパイル時のみコンパイル時計算を強制する実行時の呼び出しを禁止

どのような基準で選ぶべきか?

基本は constexpr を使う

現代のC++では、定数として扱いたいものにはまずconstexprを検討します。

これにより、コンパイル時最適化の恩恵を最大限に受けられます。

実行時の入力に依存する場合は const を使う

ユーザーの入力値やファイルの読み込み結果など、プログラムを動かしてみるまで値が確定しないものを保護するには、constを使用します。

コンパイル時計算を厳密に強制したい場合は consteval を使う

特定の計算結果をバイナリに埋め込みたい場合や、テンプレート引数などでコンパイル時定数が必須の箇所で使うヘルパー関数に適しています。

メンバ関数の引数や戻り値の保護には const を使う

関数のインターフェース設計において「引数を書き換えない」ことを示すには、引き続きconst参照(const T&)が最も標準的です。

実践的な活用例:複雑な定数計算

C++20以降では、std::vectorstd::stringの一部もconstexpr内で扱えるようになりました。

これにより、非常に高度な事前計算が可能になっています。

C++
#include <iostream>
#include <array>
#include <algorithm>

// コンパイル時にフィボナッチ数列を生成する
constexpr std::array<int, 10> generate_fibonacci() {
    std::array<int, 10> data{};
    data[0] = 0;
    data[1] = 1;
    for (size_t i = 2; i < 10; ++i) {
        data[i] = data[i - 1] + data[i - 2];
    }
    return data;
}

int main() {
    // コンパイル時に計算された配列を受け取る
    constexpr auto fib = generate_fibonacci();

    for (int n : fib) {
        std::cout << n << " ";
    }
    std::cout << std::endl;

    return 0;
}
実行結果
0 1 1 2 3 5 8 13 21 34

この例では、実行時にループを回してフィボナッチ数を計算するのではなく、コンパイルが終わった時点で既に配列の中身が完成しています。

実行時の負荷はゼロであり、これは組み込みシステムやリアルタイム性が要求されるゲーム開発などで非常に強力な武器となります。

mutableキーワードによる論理的定数

constに関連して覚えておくべき重要なキーワードにmutableがあります。

これは、constメンバ関数内であっても変更を許可するメンバ変数に付与します。

「物理的にはオブジェクトの一部を変更するが、論理的にはオブジェクトの状態は変わっていない」とみなせる場合(キャッシュ処理やミューテックスのロックなど)に使用します。

C++
#include <iostream>
#include <mutex>

class DataCache {
private:
    mutable std::mutex mtx; // const関数内でもロックを操作したい
    int cached_value = 0;

public:
    int get_value() const {
        std::lock_guard<std::mutex> lock(mtx); // OK: mtxはmutable
        return cached_value;
    }
};

このように、constを適切に使いつつ、必要な例外をmutableで処理することで、クラスのインターフェースの整合性を保つことができます。

if constexpr によるコンパイル時条件分岐

constexprの応用として、C++17で導入されたif constexprがあります。

これは条件式が偽となった方のブランチをコンパイル対象から外す仕組みです。

定数キーワードを理解する上で、この「コンパイル時にコードを切り替える」という概念は非常に重要です。

C++
#include <iostream>
#include <type_traits>

template <typename T>
void print_value(T v) {
    if constexpr (std::is_pointer_v<T>) {
        std::cout << "Pointer value: " << *v << std::endl;
    } else {
        std::cout << "Value: " << v << std::endl;
    }
}

int main() {
    int x = 100;
    print_value(x);   // 非ポインタ版としてコンパイルされる
    print_value(&x);  // ポインタ版としてコンパイルされる
    return 0;
}

定数使用における注意点とアンチパターン

強力な定数機能ですが、使いすぎや誤用には注意が必要です。

1. 巨大な計算をconstexpr化する際のコンパイル時間

constexprconstevalでの計算があまりに複雑すぎると、コンパイル時間が極端に長くなる可能性があります。

また、再帰の深さやループ回数にはコンパイラごとに制限があるため、巨大なデータセットの処理には限度があります。

2. ヘッダファイルでの定義

constexpr変数は、内部リンケージを持つため、複数のソースファイルからインクルードされるヘッダファイルで定義しても二重定義エラーにはなりません。

ただし、C++17からはinline constexprを使用することで、プログラム全体で唯一のエンティティとして定数を定義できるようになりました。

C++
// constants.h
#ifndef CONSTANTS_H
#define CONSTANTS_H

namespace Config {
    inline constexpr double Version = 1.0;
    inline constexpr int MaxConnections = 100;
}

#endif

3. const修飾の「やりすぎ」に注意

何でもかんでもconstにすれば良いわけではありません。

例えば、関数の「値渡し」の引数に対してconstを付ける(例:void func(const int x))のは、実装内での変更を防ぐ効果はありますが、関数の宣言(インターフェース)としてはあまり意味をなさず、冗長になる場合があります。

まとめ

現代のC++における定数管理は、単なる「書き換え禁止」から「コンパイル時計算の制御」へと進化しました。

  • constは「実行時の読み取り専用」を保証し、関数の安全性やポインタの保護に役立ちます。
  • constexprは「コンパイル時計算」を可能にし、実行パフォーマンスを劇的に向上させます。
  • constevalは「コンパイル時計算を強制」し、ミスを防ぎます。
  • constinitは「静的な初期化」を確実にし、初期化順序のバグを排除します。

これらを適切に使い分けることで、バグが少なく、かつ極めて高速なプログラムを記述することが可能になります。

「可能な限り constexpr を選択し、実行時まで確定しないものに const を適用する」という方針を基本に、モダンなC++開発を進めていきましょう。

定数を制する者は、C++のパフォーマンスと安全性を制すると言っても過言ではありません。