跳过正文
  1. Articles/

std::variant 很难用!

·2215 字·5 分钟· ·
ykiko
作者
ykiko
目录

std::variant于 C++17 加入标准库,本文将讨论其加入标准的背景,以及一些使用上的问题。

sum type
#

首先来讨论一下和类型 (sum type),或者叫做 tagged union。和类型就是只能在几种可能的类型中取值的类型。

例如我们有如下两个类型

struct Circle {
    double radius;
};

struct Rectangle {
    double width;
    double height;
};

那么CircleRectangle的和类型,比如我们就叫Shape吧,在 C 语言中可以这么实现

struct Shape {
    enum Type { Circle, Rectangle } type;

    union {
        struct Circle circle;
        struct Rectangle rectangle;
    };
};

这里使用了叫做 anonymous union 的特性,相当于声明了一个对应类型的 union 成员,并且把字段名字注入到当前作用域。

这样我们就可以给Shape类型的变量赋不同类型的值,同时更新记录下赋值时的type。访问的时候反过来根据type来决定按照哪种类型访问即可。例如

void foo(Shape shape) {
    if(shape.type == Shape::Circle) {
        Circle c = shape.circle;
        printf("circle: radius is %f\n", c.radius);
    } else if(shape.type == Shape::Rectangle) {
        Rectangle r = shape.rectangle;
        printf("rectangle: width is %f, height is %f\n", r.width, r.height);
    }
}

int main() {
    Shape shape;
    shape.type = Shape::Circle;
    shape.circle.radius = 1.0;
    foo(shape);
    shape.type = Shape::Rectangle;
    shape.rectangle.width = 1.0;
    shape.rectangle.height = 2.0;
    foo(shape);
}

not trivial
#

但在 C++ 中事情就没这么简单了,考虑如下代码

struct Settings {
    enum class Type { int_, double_, string } type;

    union {
        int i;
        double d;
        std::string s;
    };
};

int main(){
    Settings settings;
    settings.type = Settings::Type::String;
    settings.s = std::string("hello");
}

这段代码其实没法通过编译,编译器会报错use of deleted function Settings::Settings()。为什么Settings的构造函数被删除了呢?这其实是因为std::string的构造函数是 not trivial 的,当union中含有 not trivial 的类型的成员的时候,编译器无法正确的生成构造函数和析构函数(不知道你要初始化或者析构哪个成员)。详情原因请见的可以参考 cppreference 上对 union 的介绍。

怎么解决呢?那就是我们自己来定义union的构造函数和析构函数。比如我们可以给它定义一个空的构造函数和析构函数,也就是什么都不做

union Value {
    int i;
    double d;
    std::string s;

    Value() {}
    ~Value() {}
};

struct Settings {
    enum class Type { int_, double_, string } type;
    Value value;
};

使用的时候则要求我们通过 placement new 显式调用构造函数来初始化某个成员,同样的,我们也要手动调用析构函数来销毁某个成员。

int main(){
    Settings settings;

    settings.type = Settings::Type::string;
    new (&settings.value.s) std::string("hello");
    std::cout << settings.value.s << std::endl;
    settings.value.s.~basic_string();

    settings.type = Settings::Type::int_;
    new (&settings.value.i) int(1);
    std::cout << settings.value.i << std::endl;
    settings.value.i.~int();
}

注意,这里不能直接赋值 (assign)。因为赋值操作其实是在调用成员函数operator=,而只有已经初始化过后的对象才能调用成员函数。

从上面的代码不难看出,如果要在 C++ 里面直接使用 union 来表示 sum type,非常麻烦。不仅要及时更新type,还要正确调用构造函数和析构函数,还要留意赋值的时机问题赋值。如果其中的某一步忘记了,就会导致 undefined behavior,这非常让人头疼。不过还好,C++17 给我们提供了std::variant来解决这个问题。

std::variant
#

直接看代码

#include <string>
#include <variant>

using Settings = std::variant<int, bool, std::string>;

int main() {
    Settings s = {1};
    s = true;
    s = std::string("hello");
}

上面的代码完全是 well defined,通过模板元编程,variant会在合适和时机处理对象的构造和析构。

它有一个index成员函数可以获取当前类型在你写的类型列表里面的索引。

Settings s;
s = std::string("hello"); // s.index() => 2
s = 1; // s.index() => 0
s = true; // s.index() => 1

使用用std::get可以从variant里面取出对应的值

Settings s;
s = std::string("hello");
std::cout << std::get<std::string>(s); // => hello

有些人可能会疑惑,我都提前知道里面存的是string了,为什么还要用std::variant呢?注意到get还有一个模板参数是整数的重载,它能解决这个问题吗?

std::cout << std::get<2>(s); // => hello

哦,我懂了。那既然能直接用index来获取,那直接下面这样写不就好了?

std::cout << std::get<s.index()>(s);

很遗憾,想法是好的,但是这样做是不行的。模板参数必须是编译期常量,而variant作为一种类型擦除的手段,其index肯定是运行时的值。怎么办呢?动态转静态,只能一个个分发。例如

if (s.index() == 0){
    std::cout << std::get<0>(s) << std::endl;
} else if (s.index() == 1){
    std::cout << std::get<1>(s) << std::endl;
} else if (s.index() == 2){
    std::cout << std::get<2>(s) << std::endl;
}

用数字的可读性是比较糟糕的,我们可以用std::holds_alternative来根据类型做判断

if (std::holds_alternative<std::string>(s)){
    std::cout << std::get<std::string>(s) << std::endl;
} else if (std::holds_alternative<int>(s)){
    std::cout << std::get<int>(s) << std::endl;
} else if (std::holds_alternative<bool>(s)){
    std::cout << std::get<bool>(s) << std::endl;
}

虽然能行,但是太多冗余代码了,有没有什么更好的办法来操作variant里面的值呢?

std::visit
#

visit这个名字其实就来源于设计模式里面的那个visitor模式。利用它,我们可以写出如下代码

Settings s;
s = std::string("hello");
auto callback = [](auto&& value){ std::cout << value << std::endl; };
std::visit(callback, s); // => hello
settings = 1;
std::visit(callback, s); // => 1

是不是很神奇呢?只需要传入一个callback,就能直接访问到variant里面的值了,不需要手动进行任何分发。软件工程领域有一条铁律:复杂度不会消失,只会转移,这里也不例外。其实visit内部帮你把callback根据variant里面的每个类型实例化了一份函数,预先打好了函数表,然后再运行时根据index直接调用函数表里面的函数就行了。

但更多时候,我们其实是想根据不同类型做不同的事情。这在其它语言中可以方便的通过模式匹配做到

Haskell:

data Settings = IntValue Int | BoolValue Bool | StringValue String
  deriving (Show, Eq)

match :: Settings -> IO ()
match (IntValue x) = putStrLn $ "Int: " ++ show (x + 1)
match (BoolValue x) = putStrLn $ "Bool: " ++ show (not x)
match (StringValue x) = putStrLn $ "String: " ++ (x ++ " ")

Rust:

enum Settings{
    Int(i32),
    Bool(bool),
    String(String),
}

fn main(){
    let settings = Settings::Int(1);
    match settings{
        Settings::Int(x) => println!("Int: {}", x + 1),
        Settings::Bool(x) => println!("Bool: {}", !x),
        Settings::String(x) => println!("String: {}", x + " "),
    }
}

很可惜,截止 C++23,C++ 还是没有模式匹配。想要在 C++ 写出类似上面代码的效果,目前有两种方案来自己模拟:

function overload:

template<typename ...Ts>
struct Overload : Ts... { using Ts::operator()...; };

template<typename ...Ts>
Overload(Ts...) -> Overload<Ts...>;

int main() {
    using Settings = std::variant<int, bool, std::string>;
    Overload overloads{
        [](int x) { std::cout << "Int: " << x << std::endl; },
        [](bool x) { std::cout << "Bool: " << std::boolalpha << x << std::endl; },
        [](std::string x) { std::cout << "String: " << x << std::endl; },
    };
    Settings settings = 1;
    std::visit(overloads, settings);
}

if constexpr:

int main() {
    using Settings = std::variant<int, bool, std::string>;
    auto callback = [](auto&& value) {
        using type = std::decay_t<decltype(value)>;
        if constexpr(std::is_same_v<type, int>) {
            std::cout << "Int: " << value + 1 << std::endl;
        } else if constexpr(std::is_same_v<type, bool>) {
            std::cout << "Bool: " << !value << std::endl;
        } else if constexpr(std::is_same_v<type, std::string>) {
            std::cout << "String: " << value << std::endl;
        }
    };
    Settings settings = 1;
    std::visit(callback, settings);
}

无论是哪种方法都比较别扭,用模板来做这种 trick,不仅编译慢报错还不好看。这也意味着目前的variant非常不好用,没有配套的语言设施来简化其操作,和模板深深地纠缠在一起,让人望而却步。