使用 C++11 复制和移动时避免代码重复

Avoid code duplication when using C++11 copy & move

本文关键字:代码 C++11 复制 移动 使用      更新时间:2023-10-16

c++ 11的"move"是一个很好的特性,但是我发现当同时使用"copy"时,很难避免代码重复(我们都讨厌这个)。下面的代码是我实现的一个简单的循环队列(不完整),两个push()方法除了一行之外几乎是相同的。

我遇到过很多类似的情况。在不使用宏的情况下如何避免这种代码重复?

=== EDIT ===

在这个特殊的例子中,重复的代码可以重构出来,放到一个单独的函数中,但有时这种重构是不可用的,或者不容易实现。

#include <cstdlib>
#include <utility>
template<typename T>
class CircularQueue {
public:
CircularQueue(long size = 32) : size{size} {
buffer = std::malloc(sizeof(T) * size);
}
~CircularQueue();
bool full() const {
return counter.in - counter.out >= size;
}
bool empty() const {
return counter.in == counter.out;
}
void push(T&& data) {
if (full()) {
throw Invalid{};
}
long offset = counter.in % size;
new (buffer + offset) T{std::forward<T>(data)};
++counter.in;
}
void push(const T& data) {
if (full()) {
throw Invalid{};
}
long offset = counter.in % size;
new (buffer + offset) T{data};
++counter.in;
}
private:
T* buffer;
long size;
struct {
long in, out;
} counter;
};

这里最简单的解决方案是使参数成为转发引用。这样你就可以只使用一个函数:

template <class U>
void push(U&& data) {
if (full()) {
throw Invalid{};
}
long offset = counter.in % size;
// please note here we construct a T object (the class template)
// from an U object (the function template)
new (buffer + offset) T{std::forward<U>(data)};
++counter.in;
}

方法也有缺点:

  • 它不是通用的,也就是说它不能总是这样做(以一种微不足道的方式)。例如,当参数不像T那么简单时(例如SomeType<T>)。

  • 延迟参数的类型检查。当使用错误的形参类型调用push时,可能会出现长而看似无关的编译错误。


顺便说一下,在您的示例中T&&不是转发引用。它是一个右值引用。这是因为T不是函数的模板形参。它属于类,所以在类实例化时就已经推导出来了。因此,编写代码的正确方法应该是:

void push(T&& data) {
...
... T{std::move(data)};
...
}
void push(const T& data) {
... T{data};
...
}

使用转发引用是一个很好的解决方案。在某些情况下,它会变得困难或烦人。作为第一步,用一个接受显式类型的接口包装它,然后在cpp文件中将它们发送给模板实现。

现在,有时第一步也会失败:如果有N个不同的参数都需要转发到容器中,这需要一个大小为2^N的接口,并且可能需要跨越多个接口层才能实现。

为了达到这个目的,我们可以携带结束操作,而不是携带或取特定类型。在最外层的接口,我们将任意类型转换为那个/那些操作。

template<class T>
struct construct {
T*(*action)(void* state,void* target)=nullptr;
void* state=nullptr;
construct()=default;
construct(T&& t):
action(
[](void*src,void*targ)->T*{
return new(targ) T( std::move(*static_cast<T*>(src)) );
}
),
state(std::addressof(t))
{}
construct(T const& t):
action(
[](void*src,void*targ)->T*{
return new(targ) T( *static_cast<T const*>(src) );
}
),
state(const_cast<void*>(std::addressof(t)))
{}
T*operator()(void* target)&&{
T* r = action(state,target);
*this = {};
return r;
}
explicit operator bool()const{return action;}
construct(construct&&o):
construct(o)
{
action=nullptr;
}
construct& operator=(construct&&o){
*this = o;
o.action = nullptr;
return *this;
}
private:
construct(construct const&)=default;
construct& operator=(construct const&)=default;
};

一旦你有了一个construct<T> ctor对象,你就可以通过std::move(ctor)(location)构造一个T的实例,其中位置是一个指针,正确对齐以存储有足够存储空间的T

constructor<T>可以从右值或左值T隐式转换。它也可以通过放置支持来增强,但这需要更多的模板才能正确地完成(或者更容易地完成更多的开销)。

生活的例子。这种模式是相对简单的类型擦除。将操作存储在函数指针中,将数据存储在空指针中,并将空指针中的数据重构到所存储的操作函数指针中。

上述类型擦除/运行时概念技术的成本适中。

我们也可以这样实现:

template<class T>
struct construct :
private std::function< T*(void*) >
{
using base = std::function< T*(void*) >;
construct() = default;
construct(T&& t):base(
[&](void* target)mutable ->T* {
return new(target) T(std::move(t));
}
) {}
construct(T const& t):base(
[&](void* target)->T* {
return new(target) T(t);
}
) {}
T* operator()(void* target)&&{
T* r = base::operator()(target);
(base&)(*this)={};
return r;
}
explicit operator bool()const{
return (bool)static_cast<base const&>(*this);
}
};

依赖于std::function为我们做类型擦除。

由于这被设计为只工作一次(我们从源代码移动),所以我强制使用一个右值上下文并消除我的状态。我还隐藏了我是std::函数的事实,因为它不遵循这些规则。

引言

在为接口添加move语义支持时引入代码复制是非常烦人的。对于每个函数,你必须做两个几乎相同的实现:一个从参数复制,一个从参数移动。如果一个函数有两个形参,这甚至不是代码复制,而是代码四倍复制:

void Func(const TArg1  &arg1, const TArg2  &arg2); // copies from both arguments
void Func(const TArg1  &arg1,       TArg2 &&arg2); // copies from the first, moves from the second
void Func(      TArg1 &&arg1, const TArg2  &arg2); // moves from the first, copies from the second
void Func(      TArg1 &&arg1,       TArg2 &&arg2); // moves from both

在一般情况下,你必须对一个函数进行2^N的重载,其中N是参数的数量。在我看来,这使得移动语义实际上无法使用。这是c++ 11中最令人失望的特性。

这个问题可能更早发生。让我们看一下下面的代码:

void Func1(const T &arg);
T Func2();
int main()
{
Func1(Func2());
return 0;
}

将临时对象传递给接受引用的函数是非常奇怪的。临时对象甚至可以没有地址,例如,它可以缓存在寄存器中。但是c++允许在接受const(且只接受const)引用的地方传递临时变量。在这种情况下,临时的生存期被延长到引用的生存期结束。如果没有这个规则,我们将不得不在这里做两个实现:

void Func1(const T& arg);
void Func1(T arg);

我不知道为什么允许在引用被接受的地方传递临时对象的规则被创建了(好吧,如果没有这个规则,我们就不能调用复制构造函数来复制一个临时对象,所以Func1(Func2())Func1void Func1(T arg)的地方无论如何都不能工作:)),但是有了这个规则,我们就不必对函数进行两次重载了。

解决方案#1:完美转发

不幸的是,没有这样简单的规则可以使同一个函数实现两次重载:一个接受const左值引用,另一个接受右值引用。相反,完美转发被设计成
template <typename U>
void Func(U &&param) // despite the fact the parameter has "U&&" type at declaration,
// it actually can be just "U&" or even "const U&", it’s due to
// the template type deducing rules
{
value = std::forward<U>(param); // use move or copy semantic depending on the 
// real type of param
}

它可能看起来像那个允许避免重复的简单规则。但它并不简单,它使用了不明显的模板"魔法"来解决问题,而且这种解决方案也有一些缺点,这是因为使用完美转发的函数必须被模板化:

  • 函数的实现必须位于头文件中。
  • 它放大了二进制大小,因为对于参数类型的每个使用组合(复制/移动),它生成单独的实现(你在源代码中有一个实现,同时你在二进制中有多达2^N个实现)。
  • 参数没有类型检查。可以向函数传递任何类型的值(因为函数接受模板类型)。实际的检查将在实际使用parameter的地方进行。这可能会产生难以理解的错误消息,并导致一些意想不到的后果。

最后一个问题可以通过为完美转发功能创建非模板包装器来解决:

public:
void push(      T &&data) { push_fwd(data); }
void push(const T  &data) { push_fwd(data); }
private:
template <typename U>
void push_fwd(U &&data)
{
// actual implementation
}

当然,只有当函数参数很少(一个或两个)时,它才能在实践中使用。否则你必须制作太多的包装器(你知道,最多可达2^N)。

解决方案#2:运行时检查可移动性

最终我得到了这样的想法:检查参数的可动性不应该在编译时完成,而应该在运行时完成。我创建了一些引用包装器类,其构造函数接受两种类型的引用(右值和const左值)。类将传递给构造函数的引用存储为const左值引用,另外还存储了传递的引用是否为右值的标志。然后可以在运行时检查原始引用是否为右值,如果是,只需将存储的引用强制转换为右值引用。

不出所料,有人在我之前想到了这个想法。他将其命名为"in idiom"(我称之为"pmp"——可能是可移动参数)。你可以在这里和这里详细阅读这个习语(关于"in"习语的原始页面,如果你真的对问题感兴趣,我建议你阅读文章的所有3部分,文章深入回顾了这个问题)。

简而言之,这个习语的实现是这样的:

template <typename T> 
class in
{
public:
in (const T& l): v_ (l), rv_ (false) {}
in (T&& r): v_ (r), rv_ (true) {}
bool rvalue () const {return rv_;}
const T& get () const {return v_;}
T&& rget () const {return std::move (const_cast<T&> (v_));}
private:
const T& v_; // original reference
bool rv_;    // whether it is rvalue-reference
};

(完整实现还包含某些类型可以隐式转换为T的特殊情况)

用法示例:

class A
{
public:
void set_vec(in<std::vector<int>> param1, in<std::vector<int>> param2)
{
if (param1.rvalue()) vec1 = param1.rget(); // move if param1 is rvalue
else                 vec1 = param1.get();  // just copy otherwise
if (param2.rvalue()) vec2 = param2.rget(); // move if param2 is rvalue
else                 vec2 = param2.get();  // just copy otherwise
}
private:
std::vector<int> vec1, vec2;
};

"in"的实现缺少copy和move构造函数。

class in
{
...
in(const in  &other): v_(other.v_), rv_(false)     {} // always makes parameter not movable
// even if the original reference
// is movable
in(      in &&other): v_(other.v_), rv_(other.rv_) {} // makes parameter movable if the
// original reference was is movable
...
};

现在我们可以这样使用它:

void func1(in<std::vector<int>> param);
void func2(in<std::vector<int>> param);
void func3(in<std::vector<int>> param)
{
func1(param); // don't move param into func1 even if original reference
// is rvalue. func1 will always use copy of param, since we
// still need param in this function
// some usage of param
// now we don’t need param
func2(std::move(param)); // move param into func2 if original reference
// is rvalue, or copy param into func2 if original
// reference is const lvalue
}

还可以重载赋值操作符:

template<typename T>
T& operator=(T &lhs, in<T> rhs)
{
if (rhs.rvalue()) lhs = rhs.rget();
else              lhs = rhs.get();
return lhs;
}

之后,我们不需要每次都检查ravlue,我们可以这样使用它:

vec1 = std::move(param1); // moves or copies depending on whether param1 is movable
vec2 = std::move(param2); // moves or copies depending on whether param2 is movable

但不幸的是c++不允许重载operator=作为全局函数(https://stackoverflow.com/a/871290/5447906)。但是我们可以将这个函数重命名为assign:

template<typename T>
void assign(T &lhs, in<T> rhs)
{
if (rhs.rvalue()) lhs = rhs.rget();
else              lhs = rhs.get();
}

,并像这样使用:

assign(vec1, std::move(param1)); // moves or copies depending on whether param1 is movable
assign(vec2, std::move(param2)); // moves or copies depending on whether param2 is movable

这也不适用于构造函数。我们不能只写:

std::vector<int> vec(std::move(param));

这要求标准库支持这个特性:

class vector
{
...
public:
vector(std::in<vector> other); // copy and move constructor
...
}

但是标准不知道任何关于我们的"在"类。这里我们不能做类似于assign的变通,所以"in"类的使用是有限的。

后记

T,const T&,T&&的参数对我来说太多了。不要再引入同样的东西(好吧,几乎一样)。T就足够了!

我更喜欢这样写:

// The function in ++++C language:
func(std::vector<int> param) // no need to specify const & or &&, param is just parameter.
// it is always reference for complex types (or for types with
// special qualifier that says that arguments of this type
// must be always passed by reference).
{
another_vec = std::move(param); // move parameter if it's movable.
// compiler hides actual rvalue-ness
// of the arguments in its ABI
}

我不知道标准委员会是否考虑过这种移动语义实现,但是在c++中做这样的改变可能太晚了,因为它们会使编译器的ABI与以前的版本不兼容。它还增加了一些运行时开销,并且可能存在我们不知道的其他问题。