【C++11】move构造函数和std::move


如果说新的语言特性使得过去的最佳实践不再成立的话,我想move构造函数和std::move所代表的move语义应该算其中一个。

在解释move引起的变化之前,这里先定义一个支持自定义move操作的类

class Foo {
public:
    explicit Foo(int value) : value_{value} {
        std::cout << "Foo(int)\n";
    }

    // copy
    Foo(const Foo &foo) : value_{foo.value_} {
        std::cout << "Foo(copy)\n";
    }

    // copy assignment
    Foo &operator=(const Foo &foo) = delete;

    // move
    Foo(Foo &&foo) {
        std::cout << "Foo(move)\n";
        value_ = foo.value_;
        foo.value_ = 0;
    }

    // move assignment
    Foo &operator=(Foo &&foo) {
        std::cout << "Foo(move assignment)\n";
        value_ = foo.value_;
        foo.value_ = 0;
        return *this;
    }

    int value() const {
        return value_;
    }

    void set_value(int value) {
        value_ = value;
    }

    ~Foo() {
        std::cout << "~Foo(" << value_ << ")\n";
    }

    friend std::ostream &operator<<(std::ostream &os, const Foo &foo) {
        os << "Foo(" << foo.value_ << ")";
        return os;
    }

private:
    int value_;
};

注意构造函数 Foo(Foo&& foo) ,和一般的reference的标记 & 不同,这里有两个 & 符号。其次,参数没有加const。

move构造函数要做的事情,是把输入参数所拥有的内容移动到自己的实例中,类似数据所有权转移。具体来说

Foo f1{1};
Foo f2 = std::move(f1}; 
// f1 becomes Foo{0}
// f2 becomes Foo{1}

上述代码中,f1的数据被转移到了f2中,f1中的数据不再可用(这里被置为0)。

这样做有什么用呢?第一个想到的就是代码中数据所有权的转移。

在给出所有权转移的例子之前,复习一下C++中的copy和borrow。

void with_operation1(Foo foo) {
}

void with_operation2(Foo &foo) {
    std::cout << foo.value() << std::endl;
    foo.set_value(2);
}

void with_operation3(const Foo &foo) {
    std::cout << foo.value() << std::endl;
    // foo.set_value(2);
}

int main() {
    Foo foo{1};
    // copy
    with_operation1(foo);
    // borrow
    with_operation2(foo);
    // borrow
    with_operation3(foo);
    return 0;
}

with_operation1中Foo会被复制,对于其他语言背景的人来说,这是必须了解和注意的。

with_operation2和with_operation3中Foo不会被复制,根据是否有const来决定是否可以修改参数中的Foo。

事情看起来很完美?似乎没有引入move语义的必要?

考虑一个FooWrapper

class FooWrapper {
public:
    explicit FooWrapper(Foo&& foo): foo_{std::move(foo)} {}
private:
    Foo foo_;
};

int main() {
    Foo foo{1};
    FooWrapper wrapper{std::move(foo)};
    // wrapper.foo_ moved here
    return 0;
}

在构造了Foo之后,需要把所有权转给FooWrapper。

在没有move之前,你可能会考虑指针。但是有了move之后,你可以通过std::move间接转移数据内容达到所有权转移的效果。转移之后main函数内栈上构造的Foo也可以安全销毁。

如果你执行上述程序,可以得到以下结果

Foo(int)
Foo(move)
~Foo(1)
~Foo(0)

也就说,FooWrapper中的foo_和main函数中的foo是两个不同的变量,std::move触发了数据转移,间接达到了所有权转移。

这里为什么一直强调是数据所有权的转移呢?原因是变量本身没有被move,这点很重要。所以变量的析构函数仍旧会被调用。假如你的变量中包含指针的话,不能简单地复制一下就结束,需要置原变量的指针为nullptr。

举个例子

class IntPointerHolder {
public:
    explicit IntPointerHolder(int *ip) : ip_{ip} {}

    // no copy
    IntPointerHolder(const IntPointerHolder &) = delete;

    // no copy assignment
    IntPointerHolder &operator=(const IntPointerHolder &) = delete;

    // move
    IntPointerHolder(IntPointerHolder &&holder) {
        ip_ = holder.ip_;
        holder.ip_ = nullptr;
    }

    // no move assignment
    IntPointerHolder &operator=(IntPointerHolder &&holder) = delete;

    ~IntPointerHolder() {
        delete ip_;
    }

private:
    int *ip_;
};

注意move构造函数里设置holder.ip_为nullptr的地方。这是必须做的,否则两个变量(原变量和目标变量)的析构函数都会被调用,造成double free问题。

上述代码其实在标准库中对应有一个unique_ptr(C++11引入),可以达到完全一样的效果。unique_ptr不支持copy,只支持move,可以帮助你写出所有权唯一的代码。比如说

class SimpleIntArray {
public:
    explicit SimpleIntArray(size_t length): array_{new int[length]}, length_{length} {
        for(int i = 0; i < length; ++i) {
            array_[i] = 0;
        }
    }
    void debug() {
        std::cout << length_ << ' ';
        if(array_) {
            for(int i = 0; i < length_; i++) {
                std::cout << array_[i] << ' ';
            }
        }
        std::cout << std::endl;
    }
private:
    std::unique_ptr<int[]> array_;
    size_t length_;
};

int main() {
    SimpleIntArray array1{3};
    array1.debug();
    SimpleIntArray array2 = std::move(array1);
    array1.debug();
    array2.debug();
    return 0;
}

输出结果为

3 0 0 0 
3 
3 0 0 0 

注意代码中没有定义move构造函数,编译器默认生成的move构造函数中会逐个move成员变量,不支持move操作的成员变量会回退到copy操作。

可以想到,如果SimpleIntArray直接操作指针,并且要处理数据所有权转移的话会是一件比较麻烦的事情。相比之下这里通过unique_ptr非常简单地实现了数据转移和指针管理。

这里注意一点,从输出来看,默认生成的move构造函数在处理length_的转移时,并没有置为0。严格来说,move之后的原数据,内部状态如何是不确定的。所以理论上不应该去调用。硬要解决的话,这里可以自定义实现move构造函数,写一个直接move的length类型或者编码实践要求。

回到之前FooWrapper的代码,可以看到有两个std::move,如果问是否可以改成一个,结论是可以,不过个人不推荐。这里重要的是理解std::move做了什么,为什么需要std::move。

class FooWrapper {
public:
    explicit FooWrapper(Foo&& foo): foo_{std::move(foo)} {}
private:
    Foo foo_;
};

int main() {
    Foo foo{1};
    FooWrapper wrapper{std::move(foo)};
    // wrapper.foo_ moved here
    return 0;
}

FooWrapper wrapper{std::move(foo)}; 这行代码中,std::move是因为FooWrapper的构造函数的参数是 Foo&& ,换句话说要求是一个rvalue。这里不具体展开什么是rvalue。只要知道对于在栈上构造的foo来说,必须通过std::move转换为FooWrapper需要的Foo&&。

作为参考,临时的Foo可以不使用std::move

FooWrapper wrapper{Foo{1}};

可以看到,临时变量的情况下只有一个FooWrapper内部的std::move(具体编码中,如果不确定是否需要std::move,可以先不加,看编译器是否报错)。

因为外层的std::move只是起到转换为rvalue的作用,所以理论上不会触发move构造函数。事实上也是这样的,实际触发move构造函数的是 foo_{std::move(foo)} 这句。注意,这里如果不加 std::move,调用的会是copy构造函数。

小结一下

  • Wrapper的构造函数中使用std::move
  • 调用Wrapper构造函数的地方看情况使用std::move,比如栈上分配的变量

老实说,要完全讲清楚什么时候用std::move必须完全理解rvalue,但是看 cppreference 上的定义一头雾水。所以个人觉得,常见pattern+自己试错可能是最好的。

C++11引入的move语义很重要的一个原因,个人认为是标准库增加了对于move的支持。你想利用好新版本的功能,而不是固守旧版本的最佳实践的话,有必要了解move带来的影响。本篇的最后,分析一下对于常规的函数输入和输出的影响。

首先是返回值

Foo makeFoo() {
    return Foo{1};
}

int main() {
    // Foo(int)
    // copy/move is omitted
    Foo foo = makeFoo();
    return 0;
}

你可能没有看到std::move也没有看到move构造函数被调用,原因是编译器的“构造函数消除”优化启用了。如果你关闭了这个优化,可以看到move构造函数被调用。假如你禁用move构造函数的话,copy构造函数被调用。

顺便说一句,C++中的函数的返回值是否可以是一个对象?个人觉得,对于一个类似factory一样返回函数内栈上分配的对象的函数的话,由于“构造函数消除”优化的关系,和通过方法传入其实没有太大区别。即使没有“构造函数优化”,默认使用move而不是copy。但是如果你说的是异常处理,并且不想用C++默认的exception机制的话,那就是另外一回事情了。

回到move语义对函数的影响,之前的with_operation系列其实还有move版本

void with_operation4(Foo&& foo) {
}
void with_operation5(Foo&& foo) {
    foo.set_value(2);
    Foo foo2 = std::move(foo);
    std::cout << foo2 << std::endl;
}
int main() {
    Foo f1{1};
    Foo f2 = std::move(f1); // f1 become Foo(0) now
    // with_operation4(f2);
    with_operation4(std::move(f2));
    with_operation5(std::move(f2));
    return 0;
}

注意with_operation4虽然要求输入是Foo&&,但是函数内部没有通过std::move转移数据,所以main函数中的f2没有任何变化。相对的,with_operation5中转移了数据,所以f2数据不再有效。

考虑一个问题,假如你希望某个函数接管某个变量的数据所有权,该怎么定义函数?

答案其实很明显,上面的几段代码中都出现了,使用 T&& 这种形式

void some_function(Foo&& foo) {
    Foo bar = std::move(foo);
}

int main() {
    Foo foo{1};
    some_function(std::move(foo));
    some_function(Foo{2});
}

这里 some_function(Foo&) 肯定不行,对于 some_function(Foo{2}) 是无法编译通过的。

some_function(Foo)可以编译通过,但是 some_function(foo) 是复制, some_function(std::move(foo)) 调用时触发一次move,some_function中又move,结果有两次move。

综上所述,对于有数据转移要求的函数,使用 T&& 这种形式。

最后一个问题,对于builder这种类(Builder设计模式),如何定义build方法?

builder这种类,很典型的从类中向外数据转移。在了解了move对于返回值的影响之后,具体可以怎么写呢?

class FooBuilder {
public:
    explicit FooBuilder(int i): foo_{i} {}
    Foo build() {
        return std::move(foo_);
    }
    Foo& build2() {
        return foo_;
    }
    Foo build3() {
        return foo_;
    }
    Foo&& build4() {
        return std::move(foo_);
    }
private:
    Foo foo_;
};

int main() {
    FooBuilder builder1{1};
    builder1.build().set_value(-1); // move, ok

    FooBuilder builder2{2};
    Foo foo2 = builder2.build();  // move, ok

    FooBuilder builder3{3};
    Foo foo3 = builder3.build2(); // copy

    FooBuilder builder4{4};
    Foo foo4 = builder4.build3(); // copy

    FooBuilder builder5{5};
    builder5.build4().set_value(-1); // not moved here
    Foo foo5 = builder5.build4();
    return 0;
}

个人在尝试了4种情况后,认为第一种即返回值是Foo,内部用std::move的方式最好。

build2结果是引用,除了赋值时会copy之外,还存在可以通过build2修改内部foo_的问题。

build3纯粹是copy。

build4在赋值时move,但是存在通过build4修改内部foo_的问题。

最后build在赋值时move,不存在通过build修改内部foo_的问题。

总结

C++11引入的move语义带来很多变化,个人认为理解move语义对于写好C++11的代码很重要。希望我的分析对各位有用。