C++ `std::visit` 与 `std::variant`:构建编译期多态分发器

好的,没问题。咱们今天就来聊聊 C++ 里一对好基友:std::variantstd::visit。 这俩哥们儿组合在一起,能帮你打造一个编译期多态的分发器,让你的代码更加灵活、安全,还贼高效。

开场白:多态的那些事儿

话说,写代码的时候,我们经常会遇到需要处理不同类型的数据的情况。 比如,你要做一个图形处理程序,可能需要处理圆形、矩形、三角形等等。 传统的面向对象编程,通常会用继承和虚函数来实现多态。

class Shape {
public:
    virtual void draw() = 0;
};

class Circle : public Shape {
public:
    void draw() override { std::cout << "Drawing a circlen"; }
};

class Rectangle : public Shape {
public:
    void draw() override { std::cout << "Drawing a rectanglen"; }
};

这种方式当然没问题,但也有一些缺点:

  • 虚函数调用开销: 每次调用虚函数,都需要查虚函数表,这会带来一些性能开销。
  • 类型关系耦合: 你需要定义一个基类,所有派生类都必须继承它,这会增加代码的耦合度。
  • 运行时错误: 虚函数调用是在运行时决定的,如果类型不匹配,可能会导致运行时错误。

有没有一种方法,既能实现多态,又能避免这些缺点呢? 答案就是 std::variantstd::visit

std::variant:类型安全的联合体

std::variant 是 C++17 引入的一个模板类,它可以存储多个不同类型的值,但同一时刻只能存储其中一个。你可以把它看作是一个类型安全的联合体。

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

std::variant<int, double, std::string> myVar;

上面的代码定义了一个 myVar 变量,它可以存储 intdoublestd::string 类型的值。

你可以通过以下方式给 std::variant 赋值:

myVar = 10;           // 存储 int
myVar = 3.14;         // 存储 double
myVar = "Hello";      // 存储 std::string

std::variant 会自动跟踪当前存储的类型。 你可以使用 std::holds_alternative 来检查 std::variant 是否存储了某种类型的值:

if (std::holds_alternative<int>(myVar)) {
    std::cout << "myVar holds an intn";
} else if (std::holds_alternative<double>(myVar)) {
    std::cout << "myVar holds a doublen";
} else {
    std::cout << "myVar holds a stringn";
}

std::visit:访问 std::variant 的利器

std::visit 是一个函数模板,它可以根据 std::variant 中存储的类型,调用不同的函数。 这就是实现编译期多态的关键。

#include <variant>
#include <iostream>

int main() {
    std::variant<int, double> var;
    var = 10;

    std::visit([](auto&& arg){
        using T = std::decay_t<decltype(arg)>;
        if constexpr (std::is_same_v<T, int>){
            std::cout << "It's an int: " << arg << "n";
        } else {
             std::cout << "It's a double: " << arg << "n";
        }

    }, var);

    var = 3.14;

    std::visit([](auto&& arg){
        using T = std::decay_t<decltype(arg)>;
        if constexpr (std::is_same_v<T, int>){
            std::cout << "It's an int: " << arg << "n";
        } else {
             std::cout << "It's a double: " << arg << "n";
        }
    }, var);

    return 0;
}

上面的代码定义了一个 lambda 表达式,它接受一个参数 arg,这个参数的类型就是 std::variant 中存储的类型。 std::visit 会根据 var 中存储的类型,自动调用相应的 lambda 表达式。

lambda 表达式内部使用 if constexpr 根据类型进行不同的处理.

编译期多态分发器:std::variant + std::visit

现在,我们就可以用 std::variantstd::visit 来构建一个编译期多态的分发器了。 假设我们有一个 Shape 类,它有 draw 方法。 我们想根据 Shape 的类型,调用不同的 draw 方法。

#include <variant>
#include <iostream>

class Circle {
public:
    void draw() { std::cout << "Drawing a circlen"; }
};

class Rectangle {
public:
    void draw() { std::cout << "Drawing a rectanglen"; }
};

using Shape = std::variant<Circle, Rectangle>;

void drawShape(const Shape& shape) {
    std::visit([](auto&& arg){ arg.draw(); }, shape);
}

int main() {
    Shape circle = Circle{};
    Shape rectangle = Rectangle{};

    drawShape(circle);    // 输出 "Drawing a circle"
    drawShape(rectangle); // 输出 "Drawing a rectangle"

    return 0;
}

在上面的代码中,我们首先定义了 CircleRectangle 类,它们都有 draw 方法。 然后,我们用 std::variant 定义了一个 Shape 类型,它可以存储 CircleRectangle 对象。

drawShape 函数接受一个 Shape 类型的参数,并使用 std::visit 调用相应的 draw 方法。 关键在于 std::visit 使用的 lambda 表达式 [](auto&& arg){ arg.draw(); }。 这个 lambda 表达式可以接受任何类型的参数,只要这个类型有 draw 方法。

更灵活的 std::visit

std::visit 还可以接受多个 std::variant 作为参数。 假设我们有两个 std::variant,一个是 Shape 类型,一个是 Color 类型。 我们想根据 ShapeColor 的类型,调用不同的函数。

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

class Circle {
public:
    void draw(const std::string& color) { std::cout << "Drawing a " << color << " circlen"; }
};

class Rectangle {
public:
    void draw(const std::string& color) { std::cout << "Drawing a " << color << " rectanglen"; }
};

using Shape = std::variant<Circle, Rectangle>;
using Color = std::variant<std::string, int>;

void drawShape(const Shape& shape, const Color& color) {
    std::visit([](auto&& shape_arg, auto&& color_arg){
        //处理颜色, 确保是string
        std::string actual_color;

        if constexpr(std::is_same_v<std::decay_t<decltype(color_arg)>, std::string>){
            actual_color = color_arg;
        } else {
            actual_color = "Color#" + std::to_string(color_arg);
        }

        shape_arg.draw(actual_color);

    }, shape, color);
}

int main() {
    Shape circle = Circle{};
    Shape rectangle = Rectangle{};
    Color red = std::string{"red"};
    Color blue = 0x0000FF;

    drawShape(circle, red);       // 输出 "Drawing a red circle"
    drawShape(rectangle, blue);    // 输出 "Drawing a Color#255 rectangle"

    return 0;
}

上面的代码定义了一个 Color 类型,它可以存储 std::stringint 类型的值。 drawShape 函数接受一个 Shape 和一个 Color 类型的参数,并使用 std::visit 调用相应的 draw 方法。

lambda 表达式现在接受两个参数,分别对应 ShapeColor 的类型。 我们在 lambda 表达式内部,根据 Color 的类型,选择合适的颜色值。

std::monostate:处理空 std::variant

有时候,我们可能需要一个空的 std::variant。 比如,我们想表示一个可选的 Shape。 如果 Shape 为空,我们就什么也不做。 这时,可以使用 std::monostate

#include <variant>
#include <iostream>

class Circle {
public:
    void draw() { std::cout << "Drawing a circlen"; }
};

class Rectangle {
public:
    void draw() { std::cout << "Drawing a rectanglen"; }
};

using Shape = std::variant<std::monostate, Circle, Rectangle>;

void drawShape(const Shape& shape) {
    std::visit([](auto&& arg){
        using T = std::decay_t<decltype(arg)>;
        if constexpr (!std::is_same_v<T, std::monostate>){
            arg.draw();
        } else {
            std::cout << "No shape to drawn";
        }

    }, shape);
}

int main() {
    Shape circle = Circle{};
    Shape rectangle = Rectangle{};
    Shape empty; // 默认构造函数会创建一个 std::monostate

    drawShape(circle);    // 输出 "Drawing a circle"
    drawShape(rectangle); // 输出 "Drawing a rectangle"
    drawShape(empty);     // 输出 "No shape to draw"

    return 0;
}

std::monostate 是一个空类,它只有一个实例。 当 std::variant 存储 std::monostate 时,表示它为空。 在 drawShape 函数中,我们使用 std::is_same_v 来判断 Shape 是否为空。 如果为空,我们就输出 "No shape to draw"。

异常处理:std::bad_variant_access

如果我们在访问 std::variant 时,它存储的类型与我们期望的类型不匹配,std::visit 会抛出一个 std::bad_variant_access 异常。

#include <variant>
#include <iostream>

int main() {
    std::variant<int, double> var = 3.14;

    try {
        std::cout << std::get<int>(var) << std::endl; // 尝试获取 int,但 var 存储的是 double
    } catch (const std::bad_variant_access& e) {
        std::cerr << "Error: " << e.what() << std::endl; // 输出 "Error: bad variant access"
    }

    return 0;
}

为了避免 std::bad_variant_access 异常,我们可以使用 std::get_if 来安全地访问 std::variantstd::get_if 会返回一个指向 std::variant 中存储的值的指针,如果类型不匹配,则返回 nullptr

#include <variant>
#include <iostream>

int main() {
    std::variant<int, double> var = 3.14;

    int* int_ptr = std::get_if<int>(&var);
    if (int_ptr) {
        std::cout << *int_ptr << std::endl;
    } else {
        std::cout << "var does not hold an intn"; // 输出 "var does not hold an int"
    }

    return 0;
}

std::variant 的一些注意事项

  • 没有默认构造函数: 如果 std::variant 包含的类型没有默认构造函数,那么 std::variant 也没有默认构造函数。 你需要显式地初始化 std::variant
  • 复制和移动: std::variant 的复制和移动操作会复制或移动其中存储的值。 如果 std::variant 包含的类型不支持复制或移动,那么 std::variant 也不支持复制或移动。
  • 递归类型: std::variant 不能包含自身。 也就是说,你不能定义一个 std::variant<int, std::variant<int, ...>> 这样的类型。 如果确实需要递归类型,可以考虑使用 std::recursive_wrapper

std::visit 的一些高级用法

  • 完美转发: std::visit 可以完美转发参数给 lambda 表达式。 也就是说,lambda 表达式可以接受左值引用、右值引用或 const 引用。
  • 返回值: std::visit 的返回值是 lambda 表达式的返回值。 所有 lambda 表达式必须返回相同的类型,或者可以隐式转换为相同的类型。
  • 状态: 你可以通过 capture list 将状态传递给 lambda 表达式。

总结:std::variantstd::visit 的优势

  • 编译期多态: 类型检查在编译期完成,避免了运行时错误。
  • 零开销: 没有虚函数调用开销,性能更高。
  • 类型安全: std::variant 保证了类型安全,避免了类型转换错误。
  • 灵活性: 可以处理多种不同类型的数据。
  • 可读性: 代码更加简洁易懂。

使用场景

  • 编译器: 语法树节点可以使用 std::variant 来表示不同的语法结构。
  • 解释器: 可以用 std::variant 来表示不同的数据类型。
  • 游戏开发: 可以用 std::variant 来表示不同的游戏对象类型。
  • 网络编程: 可以用 std::variant 来表示不同的网络消息类型。
  • 任何需要处理多种类型数据的场景。

总而言之, std::variantstd::visit 是一对非常强大的工具,它们可以帮助你编写更加灵活、安全、高效的 C++ 代码。 掌握它们,你会发现你的代码更加优雅,你的程序更加健壮。 希望今天的讲解对你有所帮助,咱们下期再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注