一、背景
c++ 代码似乎经常出现一个问题:如果该值可以来自左值或右值,则对象如何跟踪该值?即如果保留该值作为引用,那么就无法绑定到临时对象。如果将其保留为一个值,那么当它从左值初始化时,会产生不必要的副本。
有几种方法可以应对这种情况。使用std::variant提供了一个很好的折衷方案来获得有表现力的代码。
二、跟踪值
假设有一个类myclass
。想让myclass
访问某个std::string
。如何表示myclass
内部的字符串?
有两种选择:
- 将其存储为引用。
- 将其存储为副本。
2.1、存储引用
如果将其存储为引用,例如const引用:
class myclass { public: explicit myclass(std::string const& s) : s_(s) {} void print() const { std::cout << s_ << '\n'; } private: std::string const& s_; };
则可以用一个左值初始化我们的引用:
std::string s = "hello"; myclass myobject{s}; myobject.print();
看起来很不错。但是,如果想用右值初始化我们的对象呢?例如:
myclass myobject{std::string{"hello"}}; myobject.print();
或者这样的代码:
std::string getstring(); // function declaration returning by value myclass myobject{getstring()}; myobject.print();
那么代码具有未定义的行为。原因是,临时字符串对象在创建它的同一条语句中被销毁。当调用print
时,字符串已经被破坏,使用它是非法的,并导致未定义的行为。
为了说明这一点,如果将std::string
替换为类型x
,并且在x
的析构函数打印日志:
struct x { ~x() { std::cout << "x destroyed" << '\n';} }; class myclass { public: explicit myclass(x const& x) : x_(x) {} void print() const { // using x_; } private: x const& x_; };
在调用的地方也打印日志:
myclass myobject(x{}); std::cout << "before print" << '\n'; myobject.print();
输出:
x destroyed before print
可以看到,在尝试使用之前,这个x
已经被破坏了。
完整示例:
#include <iostream> #include <string> struct x { ~x() { std::cout << "x destroyed" << '\n';} }; class myclass { public: explicit myclass(x const& x) : x_(x) {} void print() { (void) x_; // using x_; } private: x const& x_; }; int main() { myclass myobject(x{}); std::cout << "before print" << '\n'; myobject.print(); }
2.2、存储值
另一种选择是存储一个值。这允许使用move
语义将传入的临时值移动到存储值中:
class myclass { public: explicit myclass(std::string s) : s_(std::move(s)) {} void print() const { std::cout << s_ << '\n'; } private: std::string s_; };
现在调用它:
myclass myobject{std::string{"hello"}}; myobject.print();
产生两次移动(一次构造s
,一次构造s_
),并且没有未定义的行为。实际上,即使临时对象被销毁,print
也会使用类内部的实例。
不幸的是,如果带着左值返回到第一个调用点:
std::string s = "hello"; myclass myobject{s}; myobject.print();
那么就不再做两次移动了:做了一次复制(构造s)和一次移动(构造s_)。
更重要的是,我们的目的是给myclass访问字符串的权限,如果做一个拷贝,就有了一个不同于进来的实例。所以它们不会同步。
对于临时对象来说,这不是问题,因为它无论如何都会被销毁,并且我们在之前将它移了进来,所以仍然可以访问字符串。但是通过复制,我们不再给myclass访问传入字符串的权限。
所以存储一个值也不是一个好的解决方案。
三、存储variant
存储引用不是一个好的解决方案,存储值也不是一个好的解决方案。我们想做的是,如果引用是从左值初始化的,则存储引用;如果引用是从右值初始化的,则存储引用。
但是数据成员只能是一种类型:值或引用,对吗?
但是,对于std::variant
,它可以是任意一个。不过,如果尝试在一个变量中存储引用,就像这样:
std::variant<std::string, std::string const&>
将得到一个编译错误:
variant must have no reference alternative
为了达到我们的目的,需要将引用放在另一个类型中;即必须编写特定的代码来处理数据成员。如果为std::string
编写这样的代码,则不能将其用于其他类型。
在这一点上,最好以通用的方式编写代码。
四、通用存储类
存储需要是一个值或一个引用。既然现在是为通用目的编写这段代码,那么也可以允许非const
引用。由于变量不能直接保存引用,那么可以将它们存储到包装器中:
template<typename t> struct nonconstreference { t& value_; explicit nonconstreference(t& value) : value_(value){}; }; template<typename t> struct constreference { t const& value_; explicit constreference(t const& value) : value_(value){}; }; template<typename t> struct value { t value_; explicit value(t&& value) : value_(std::move(value)) {} };
将存储定义为这两种情况之一:
template<typename t> using storage = std::variant<value<t>, constreference<t>, nonconstreference<t>>;
现在需要通过提供引用来访问变量的底层值。创建了两种类型的访问:一种是const
,另一种是非const
。
4.1、定义const访问
要定义const
访问,需要使变量内部的三种可能类型中的每一种都产生一个const
引用。
为了访问变量中的数据,将使用std::visit
和规范的overload
模式,这可以在c++ 17中实现:
template<typename... functions> struct overload : functions... { using functions::operator()...; overload(functions... functions) : functions(functions)... {} };
要获得const
引用,只需为每种variant
创建一个:
template<typename t> t const& getconstreference(storage<t> const& storage) { return std::visit( overload( [](value<t> const& value) -> t const& { return value.value_; }, [](nonconstreference<t> const& value) -> t const& { return value.value_; }, [](constreference<t> const& value) -> t const& { return value.value_; } ), storage ); }
4.2、定义非const访问
非const引用的创建使用相同的技术,除了variant
是constreference
之外,它不能产生非const引用。然而,当std::visit
访问一个变量时,必须为它的每一个可能的类型编写代码:
template<typename t> t& getreference(storage<t>& storage) { return std::visit( overload( [](value<t>& value) -> t& { return value.value_; }, [](nonconstreference<t>& value) -> t& { return value.value_; }, [](constreference<t>& ) -> t&. { /* code handling the error! */ } ), storage ); }
进一步优化,抛出一个异常:
struct nonconstreferencefromreference : public std::runtime_error { explicit nonconstreferencefromreference(std::string const& what) : std::runtime_error{what} {} }; template<typename t> t& getreference(storage<t>& storage) { return std::visit( overload( [](value<t>& value) -> t& { return value.value_; }, [](nonconstreference<t>& value) -> t& { return value.value_; }, [](constreference<t>& ) -> t& { throw nonconstreferencefromreference{"cannot get a non const reference from a const reference"} ; } ), storage ); }
五、创建存储
已经定义了存储类,可以在示例中使用它来访问传入的std::string
,而不管它的值类别:
class myclass { public: explicit myclass(std::string& value) : storage_(nonconstreference(value)){} explicit myclass(std::string const& value) : storage_(constreference(value)){} explicit myclass(std::string&& value) : storage_(value(std::move(value))){} void print() const { std::cout << getconstreference(storage_) << '\n'; } private: storage<std::string> storage_; };
(1)调用时带左值:
std::string s = "hello"; myclass myobject{s}; myobject.print();
匹配第一个构造函数,并在存储成员内部创建一个nonconstreference
。当print
函数调用getconstreference
时,非const
引用被转换为const
引用。
(2)使用临时值:
myclass myobject{std::string{"hello"}}; myobject.print();
这个函数匹配第三个构造函数,并将值移动到存储中。getconstreference然后将该值的const引用返回给print函数。
六、总结
variant为c++中跟踪左值或右值的经典问题提供了一种非常适合的解决方案。这种技术的代码具有表现力,因为std::variant允许表达与我们的意图非常接近的东西:“根据上下文,对象可以是引用或值”。
在c++ 17和std::variant之前,解决这个问题很棘手,导致代码难以正确编写。随着语言的发展,标准库变得越来越强大,可以用越来越多的表达性代码来表达我们的意图。
以上就是c++在同一对象中存储左值或右值的方法的详细内容,更多关于c++同一对象存储左值的资料请关注代码网其它相关文章!
发表评论