C++のunion(共用体)の使い方について

AI実装検定のご案内

C++のunionは、複数のメンバーで同じメモリ領域を共有するためのユーザー定義型です。

日本語では「共用体」と呼ばれます。

通常のstructでは、各メンバーにそれぞれ独立したメモリ領域が割り当てられます。

一方、unionでは、すべてのメンバーが基本的に同じメモリ領域を利用します。

そのため、複数のメンバーを定義できるものの、原則として同時に有効な値として扱えるメンバーは1つだけです。

union Data {
    int integer;
    double decimal;
    char character;
};

この例では、integerdecimalcharacterが同じメモリ領域を共有します。

したがって、integerに値を保存したあとでdecimalに値を保存すると、以前のintegerの値をそのまま保持することはできません。

目次

unionとstructの違い

structはすべてのメンバーを同時に保持できる

structでは、各メンバーが別々のメモリ領域を持ちます。

struct StructData {
    int integer;
    double decimal;
    char character;
};

次のように、すべてのメンバーへ同時に値を保存できます。

StructData data{};

data.integer = 100;
data.decimal = 3.14;
data.character = 'A';

integerdecimalcharacterは、それぞれ独立した値として保持されます。

unionはメモリ領域を共有する

unionでは、すべてのメンバーが同じ記憶領域を共有します。

union UnionData {
    int integer;
    double decimal;
    char character;
};

この場合、integerへ値を保存したあとでdecimalへ値を保存すると、現在有効なメンバーは基本的にdecimalになります。

UnionData data{};

data.integer = 100;
data.decimal = 3.14;

最後に値を保存したdecimalが、現在利用すべきメンバーです。

unionのサイズ

unionのサイズは、少なくとも最も大きなメンバーを格納できる大きさになります。

#include <iostream>

union Data {
    int integer;
    double decimal;
    char character;
};

int main() {
    std::cout << sizeof(Data) << '\n';
}

ただし、sizeof(Data)が必ず最大メンバーのsizeofと完全に一致するとは限りません。

アライメントやパディングの影響により、処理系ごとにサイズが変わる可能性があります。

unionの基本的な定義方法

名前付きunionを定義する

基本的な構文は、structclassと似ています。

union Number {
    int integer;
    double decimal;
};

C++におけるunionは、クラス型の一種です。

メンバーのデフォルトアクセスはpublicです。

そのため、次のように外部からメンバーへアクセスできます。

Number number{};

number.integer = 100;

union型の変数を作る

定義した共用体から変数を作るには、通常の型と同じように記述します。

Number number;

ただし、この書き方では、組み込み型のメンバーが有効な値で初期化されるとは限りません。

未初期化の値を誤って読み出さないためにも、波括弧を使って初期化するのが安全です。

Number number{};

このような単純な共用体では、先頭メンバーであるintegerがゼロ初期化されます。

#include <iostream>

union Number {
    int integer;
    double decimal;
};

int main() {
    Number number{};

    std::cout << number.integer << '\n';
}

このコードでは、0が表示されます。

unionの初期化方法

先頭メンバーを初期化する

共用体が集成体として扱われる場合、次のように先頭メンバーを初期化できます。

union Number {
    int integer;
    double decimal;
};

Number number{42};

この場合、先頭メンバーであるinteger42で初期化されます。

現在有効なメンバーもintegerです。

C++20の指示付き初期化を使う

C++20以降では、初期化するメンバーを名前で指定できます。

union Number {
    int integer;
    double decimal;
};

Number number{
    .decimal = 3.14
};

この場合、decimalが初期化され、現在有効なメンバーになります。

指示付き初期化を使うには、コンパイル時にC++20以降を指定する必要があります。

g++ -std=c++20 main.cpp

共用体では、複数の非静的データメンバーを同時に指示付き初期化することはできません。

アクティブメンバーとは

現在有効なメンバーを表す考え方

共用体では、現在有効なメンバーを「アクティブメンバー」と呼びます。

より厳密には、オブジェクトとしての寿命が開始され、まだ終了していないメンバーがアクティブメンバーです。

union Value {
    int integer;
    double decimal;
};

Value value{};

value.integer = 100;

この時点では、integerがアクティブメンバーです。

その後、decimalへ値を代入すると、decimalがアクティブメンバーになります。

value.decimal = 3.14;

組み込み型のような単純な型では、このように代入によって有効なメンバーを切り替えられます。

非アクティブメンバーを読み出してはいけない

次のコードでは、integerに値を代入したあとでdecimalを読み出しています。

union Value {
    int integer;
    double decimal;
};

Value value{};
value.integer = 100;

double result = value.decimal;

標準C++では、このような非アクティブメンバーの読み出しを、一般的な型変換方法として使用してはいけません。

環境やコンパイラーによっては期待したように動作する場合がありますが、移植可能なコードにはなりません。

基本的には、値を書き込んだメンバーと同じメンバーから読み出します。

Value value{};
value.integer = 100;

int result = value.integer;

タグ付きunionの使い方

タグで現在の型を管理する

共用体だけでは、現在どのメンバーが有効なのかを自動的に確認できません。

そこで、列挙型などのタグを組み合わせて管理する方法があります。

この設計は、タグ付き共用体や判別共用体と呼ばれます。

enum class ValueType {
    Integer,
    Decimal
};

struct Value {
    ValueType type;

    union {
        int integer;
        double decimal;
    };
};

typeを確認することで、どのメンバーを読み出すべきか判断できます。

#include <iostream>

enum class ValueType {
    Integer,
    Decimal
};

struct Value {
    ValueType type;

    union {
        int integer;
        double decimal;
    };
};

int main() {
    Value value{
        ValueType::Integer,
        {.integer = 100}
    };

    if (value.type == ValueType::Integer) {
        std::cout << value.integer << '\n';
    }
}

タグとメンバーを一致させる

タグ付き共用体では、タグとアクティブメンバーを必ず一致させる必要があります。

次のような状態は危険です。

Value value{
    ValueType::Integer,
    {.integer = 100}
};

value.type = ValueType::Decimal;

このコードでは、実際に値が保存されているのはintegerですが、タグだけがDecimalへ変更されています。

この状態でdecimalを読み出すと、不正なアクセスにつながります。

切り替え処理を関数にまとめる

安全性を高めるには、メンバーとタグを同時に変更する関数を用意します。

#include <cassert>
#include <iostream>

class Value {
public:
    enum class Type {
        Integer,
        Decimal
    };

private:
    Type type_;

    union {
        int integer_;
        double decimal_;
    };

public:
    explicit Value(int value)
        : type_(Type::Integer),
          integer_(value) {
    }

    explicit Value(double value)
        : type_(Type::Decimal),
          decimal_(value) {
    }

    void set_integer(int value) {
        integer_ = value;
        type_ = Type::Integer;
    }

    void set_decimal(double value) {
        decimal_ = value;
        type_ = Type::Decimal;
    }

    int get_integer() const {
        assert(type_ == Type::Integer);
        return integer_;
    }

    double get_decimal() const {
        assert(type_ == Type::Decimal);
        return decimal_;
    }

    Type type() const noexcept {
        return type_;
    }
};

int main() {
    Value value(100);

    value.set_decimal(3.14);

    if (value.type() == Value::Type::Decimal) {
        std::cout << value.get_decimal() << '\n';
    }
}

タグとメンバーを外部から個別に変更できない設計にすると、状態の不一致を防ぎやすくなります。

無名unionの使い方

共用体名を省略する

union自体に名前を付けず、メンバーへ直接アクセスできる形を無名共用体と呼びます。

struct Value {
    bool is_integer;

    union {
        int integer;
        double decimal;
    };
};

使用時は、共用体の変数名を挟まずにメンバーへアクセスできます。

Value value{};

value.is_integer = true;
value.integer = 100;

名前付きunionとの違い

共用体に名前と変数名を付ける場合は、次のようになります。

struct Value {
    bool is_integer;

    union Storage {
        int integer;
        double decimal;
    } storage;
};

使用時には、storageを経由してアクセスします。

Value value{};

value.is_integer = true;
value.storage.integer = 100;

無名共用体は記述を短くできますが、外側のスコープにメンバー名が現れるため、名前の衝突に注意が必要です。

クラス型をunionのメンバーにする方法

std::stringもunionに格納できる

C++では、std::stringのようにコンストラクターやデストラクターを持つクラス型も、共用体のメンバーにできます。

#include <string>

union Data {
    int number;
    std::string text;

    Data() {
    }

    ~Data() {
    }
};

ただし、組み込み型だけを格納する場合とは異なり、オブジェクトの構築と破棄を手動で管理しなければなりません。

空のデストラクターを定義しても、std::stringのデストラクターが自動的に呼ばれるわけではありません。

placement newでオブジェクトを構築する

共用体のメモリ領域上にstd::stringを構築するには、placement newを利用できます。

#include <iostream>
#include <new>
#include <string>

union Data {
    int number;
    std::string text;

    Data() {
    }

    ~Data() {
    }
};

int main() {
    Data data;

    new (&data.text) std::string("Hello");

    std::cout << data.text << '\n';

    data.text.~basic_string();
}

new (&data.text) std::string("Hello")によって、data.textの記憶領域上にstd::stringオブジェクトが構築されます。

使用後は、明示的にデストラクターを呼び出しています。

data.text.~basic_string();

構築と破棄は必ず対にする必要があります。

C++20ではconstruct_atも使える

C++20以降では、std::construct_atstd::destroy_atを利用できます。

#include <iostream>
#include <memory>
#include <string>

union Data {
    int number;
    std::string text;

    Data() {
    }

    ~Data() {
    }
};

int main() {
    Data data;

    std::construct_at(&data.text, "Hello");

    std::cout << data.text << '\n';

    std::destroy_at(&data.text);
}

placement newを直接記述する方法と同様に、構築したオブジェクトは必ず適切に破棄します。

非トリビアル型を持つunionの注意点

コピーやムーブも管理する必要がある

std::stringstd::vectorのような非トリビアル型を共用体へ格納する場合、デストラクターだけでなく、次の処理も考慮する必要があります。

  • コピーコンストラクター
  • ムーブコンストラクター
  • コピー代入演算子
  • ムーブ代入演算子
  • アクティブメンバーの切り替え
  • 例外が発生した場合の状態管理

これらを正しく実装しないままコピーやムーブを許可すると、オブジェクトの寿命管理に問題が生じる可能性があります。

実装しない場合は、明示的に削除する方法があります。

Value(const Value&) = delete;
Value& operator=(const Value&) = delete;
Value(Value&&) = delete;
Value& operator=(Value&&) = delete;

取得時に型を確認する

共用体をクラスで包む場合は、取得関数の中でも現在の型を確認するべきです。

#include <stdexcept>

int get_integer() const {
    if (type_ != Type::Integer) {
        throw std::logic_error("Value does not contain an integer");
    }

    return storage_.integer;
}

型チェックを行わずに非アクティブメンバーを返すと、利用側の誤操作によって未定義動作につながる可能性があります。

unionを型変換に使うべきではない理由

異なるメンバーから読み出す方法は移植性が低い

次のように、floatのビット列を整数として読み出す目的でunionが使われることがあります。

#include <cstdint>

union Converter {
    float floating;
    std::uint32_t bits;
};

Converter converter{};
converter.floating = 1.0F;

std::uint32_t bits = converter.bits;

一部のコンパイラーや環境では期待したように動作する可能性があります。

しかし、標準C++における移植可能なビット変換方法として、この書き方へ依存すべきではありません。

C++20ではstd::bit_castを使う

C++20以降では、ビット表現を変換する目的でstd::bit_castを利用できます。

#include <bit>
#include <cstdint>
#include <iostream>

int main() {
    static_assert(sizeof(float) == sizeof(std::uint32_t));

    const float value = 1.0F;

    const std::uint32_t bits =
        std::bit_cast<std::uint32_t>(value);

    std::cout << bits << '\n';
}

std::bit_castを使うには、変換元と変換先のサイズが同じである必要があります。

さらに、変換元と変換先は、どちらもトリビアルコピー可能型でなければなりません。

多くの環境ではfloatstd::uint32_tが4バイトですが、C++規格がすべての環境で同じサイズになることを保証しているわけではありません。

そのため、static_assertでサイズを確認しています。

C++17以前ではstd::memcpyを使う

C++17以前にも対応する場合は、std::memcpyを使ってビット列をコピーできます。

#include <cstdint>
#include <cstring>
#include <iostream>

int main() {
    static_assert(sizeof(float) == sizeof(std::uint32_t));

    const float value = 1.0F;
    std::uint32_t bits{};

    std::memcpy(&bits, &value, sizeof(bits));

    std::cout << bits << '\n';
}

単なるビット変換を目的とする場合は、生のunionよりもstd::bit_caststd::memcpyのほうが意図を明確にできます。

共通初期シーケンスとは

標準レイアウト型に認められる限定的な例外

標準レイアウト型の構造体が、先頭部分に互換性のあるメンバーを持つ場合、共通初期シーケンスという規則が適用されることがあります。

struct A {
    int type;
    int value;
};

struct B {
    int type;
    double value;
};

union Data {
    A a;
    B b;
};

ABの先頭にあるtypeは、条件を満たせば共通初期シーケンスに含まれます。

Data data{
    .a = {1, 100}
};

int type = data.b.type;

このように、aがアクティブな状態でも、共通初期シーケンスに含まれるb.typeを確認できる場合があります。

共通部分以外は読み出せない

共通初期シーケンスとして認められるのは、条件を満たす先頭部分だけです。

int type = data.b.type;

この読み出しが認められる場合でも、次のようにb.valueまで読み出せるわけではありません。

double value = data.b.value;

共通初期シーケンスには細かな成立条件があります。

一般的な型変換方法として利用するのではなく、低レベルなデータ構造で必要な場合に限って慎重に使用するべきです。

C言語のAPIとunionを連携する方法

extern “C”は関数宣言に使用する

C言語のAPIと連携するとき、extern "C"は主に関数の言語リンケージを指定するために使います。

共用体の定義をextern "C"で囲んだだけでは、C言語とのメモリ配置互換性が自動的に保証されるわけではありません。

CとC++の両方から利用するヘッダーファイルでは、次のように記述できます。

#ifndef C_VALUE_H
#define C_VALUE_H

union CValue {
    int integer;
    double decimal;
};

#ifdef __cplusplus
extern "C" {
#endif

void process_value(union CValue value);

#ifdef __cplusplus
}
#endif

#endif

この例では、union CValueの定義は通常の形で記述し、C関数の宣言をextern "C"の対象にしています。

バイナリ互換性には追加確認が必要

C言語のAPIや外部バイナリと連携する場合は、次の点も確認する必要があります。

  • 型のサイズ
  • アライメント
  • パディング
  • エンディアン
  • ABI
  • コンパイラーの設定
  • プラットフォーム固有の仕様

同じメンバーを持つ共用体を定義しただけで、すべての環境におけるバイナリ互換性が保証されるわけではありません。

unionの主な用途

メモリ使用量を抑える

複数の値のうち、同時に1つしか使用しないことが明確な場合、unionによってメモリ使用量を抑えられる可能性があります。

union SensorValue {
    int digital;
    float analog;
};

組み込み機器やメモリ制約の厳しい環境では、有効な選択肢になることがあります。

複数形式の値を表現する

値が整数、浮動小数点数、文字など、複数の形式を取る場合にも利用できます。

enum class TokenType {
    Integer,
    Decimal,
    Character
};

struct TokenValue {
    TokenType type;

    union {
        int integer;
        double decimal;
        char character;
    };
};

コンパイラー、字句解析器、構文解析器、仮想マシンなどで見られる設計です。

C言語のデータ構造と連携する

C言語で定義されたAPIや構造体をC++から扱う場合、対応する共用体が必要になることがあります。

ただし、メモリ配置やABIの条件を事前に確認する必要があります。

組み込み開発や低レベル処理に使う

組み込み開発、デバイスドライバー、通信処理、ハードウェア制御などでは、限られたメモリ領域を複数の用途で利用するためにunionが使われることがあります。

ただし、ハードウェアレジスターや通信データの解釈では、エンディアン、アライメント、ビット幅、パディングなども考慮しなければなりません。

std::variantとの違い

std::variantは現在の型を自動管理する

現代のC++で、複数の型のうち1つを保持したい場合は、通常、std::variantが第一候補です。

#include <iostream>
#include <string>
#include <variant>

int main() {
    std::variant<int, double, std::string> value;

    value = 100;
    std::cout << std::get<int>(value) << '\n';

    value = std::string("Hello");
    std::cout << std::get<std::string>(value) << '\n';
}

std::variantは、現在どの型の値を保持しているかを内部で管理します。

holds_alternativeで型を確認する

現在保持している型は、std::holds_alternativeで確認できます。

if (std::holds_alternative<int>(value)) {
    std::cout << std::get<int>(value) << '\n';
}

誤った型をstd::getで取得しようとした場合は、std::bad_variant_accessが送出されます。

std::visitで型ごとの処理を行う

std::visitを利用すると、現在保持している型に応じた処理を書けます。

#include <iostream>
#include <string>
#include <variant>

int main() {
    std::variant<int, double, std::string> value =
        std::string("Hello");

    std::visit(
        [](const auto& current) {
            std::cout << current << '\n';
        },
        value
    );
}

unionを選ぶ場面

生のunionは、次のような用途で選択されます。

  • C言語のAPIやデータ構造との互換性が必要な場合
  • メモリ配置を厳密に制御したい場合
  • 組み込み環境で管理情報を最小限にしたい場合
  • 独自の低レベルなデータ型を実装する場合
  • 外部仕様で共用体の利用が決められている場合

std::variantを選ぶ場面

一般的なC++アプリケーションでは、次の理由からstd::variantが適しています。

  • 現在の型を自動的に管理できる
  • クラス型の構築と破棄を自動処理できる
  • コピーやムーブを安全に扱いやすい
  • 間違った型の読み出しを検出できる
  • RAIIに沿った設計にしやすい
  • 保守性の高いコードを書きやすい

ただし、std::variantは型を識別するための管理情報を持つため、必ずしも生のunionと同じサイズになるわけではありません。

また、C言語のABIや特定のバイナリレイアウトと直接一致することも保証されません。

unionを使う際の注意点

書き込んだメンバーと同じメンバーを読み出す

基本的には、最後に値を書き込んだメンバーと同じメンバーを読み出します。

union Data {
    int integer;
    double decimal;
};

Data data{};
data.integer = 100;

std::cout << data.integer << '\n';

異なるメンバーから読み出して型変換する方法には依存しないようにします。

現在の型を必ず管理する

複数の型を切り替えて使用する場合は、列挙型などのタグを併用します。

enum class Type {
    Integer,
    Decimal
};

struct Data {
    Type type;

    union {
        int integer;
        double decimal;
    };
};

タグとアクティブメンバーが食い違わないように設計することが重要です。

クラス型は寿命を手動管理する

std::stringstd::vectorなどのクラス型を格納する場合は、構築と破棄を正しく管理する必要があります。

コピーやムーブも含めると実装が複雑になるため、特別な理由がなければstd::variantの利用を検討します。

型変換には専用の方法を使う

ビット表現を変換したい場合は、次の方法を利用します。

  • C++20以降ではstd::bit_cast
  • C++17以前ではstd::memcpy
  • データ形式の変換では明示的なシリアライズ処理

生のunionを安易な型変換の道具として使わないことが重要です。

サイズを決めつけない

unionのサイズは、アライメントやパディングの影響を受けます。

必要に応じて、sizeofalignofを確認します。

#include <iostream>

std::cout << sizeof(Data) << '\n';
std::cout << alignof(Data) << '\n';

特定のサイズが必要な場合は、static_assertを使ってコンパイル時に確認できます。

#include <cstdint>

static_assert(
    sizeof(std::uint32_t) == 4,
    "4バイトの整数型が必要です"
);

C++のunionに関するまとめ

C++のunionは、複数のメンバーで同じメモリ領域を共有する仕組みです。

複数の値のうち、同時に1つだけを保持する場合に、メモリ使用量を抑えられる可能性があります。

一方で、現在どのメンバーが有効なのかを、プログラム側で正確に管理しなければなりません。

特にstd::stringのような非トリビアル型を格納する場合は、構築、破棄、コピー、ムーブなどの寿命管理が必要です。

また、異なるメンバーを読み出してビット列を変換する用途には、生のunionを利用するべきではありません。

ビット変換にはstd::bit_caststd::memcpyを使用するのが適切です。

一般的なC++プログラムで複数の型のうち1つを安全に保持したい場合は、std::variantを優先するとよいでしょう。

生のunionは、C言語との連携、厳密なメモリ配置、組み込み開発など、明確な理由がある場合に選択するのが基本です。

以上、C++のunion(共用体)の使い方についてでした。

最後までお読みいただき、ありがとうございました。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次