假设我们有一个 Person
类,代码如下:
class Person {
public:
Person(const std::string &first_name, const std::string &last_name)
: first_name_(first_name), last_name_(last_name) {}
private:
std::string first_name_;
std::string last_name_;
};
这段代码看起来似乎没有问题,但实际上存在潜在的性能问题。如果像这样构造 Person
对象:
int main() {
Person person("klare", "neroll");
}
由于传递的实参是 const char *
类型,与构造函数的形参类型 const std::string &
不符,因此会编译器构造一个临时的 string 对象,并将其绑定到形参上。在这个过程中,实参字符串会被拷贝到临时对象中,这是第一次拷贝。
在构造函数的成员初始化列表中,: first_name_(first_name), last_name_(last_name)
会通过拷贝构造函数构造 Person
的两个成员,字符串再次发生拷贝,这是第二次拷贝。
可以看到,在 person
对象的构造过程中,两个参数各被拷贝了两次。在理想情况下,每个实参应该只被拷贝一次。因此,这里存在多余的拷贝。
有没有办法消除多余的拷贝呢?当然可以,再加一个构造函数,用 const char *
作为参数类型就可以了:
class Person {
public:
Person(const std::string &first_name, const std::string &last_name)
: first_name_(first_name), last_name_(last_name) {}
Person(const char *first_name, const char *last_name)
: first_name_(first_name), last_name_(last_name) {}
private:
std::string first_name_;
std::string last_name_;
};
这样,当使用两个字符串字面量构造 Person
对象时,会调用第二个构造函数,参数只会在成员初始化列表中被拷贝一次,不会有多余的拷贝。
等等,如果一个参数是 std::stirng
,另一个参数是 const char *
,会发生什么?
int main() {
std::string first_name = "klare";
Person person(first_name, "neroll");
}
这里会匹配第一个构造函数,由于第二个实参类型是 const char *
,还是构造了临时对象,造成多余拷贝。
怎么解决?难道要这么写:
class Person {
public:
Person(const std::string &first_name, const std::string &last_name)
: first_name_(first_name), last_name_(last_name) {}
Person(const char *first_name, const char *last_name)
: first_name_(first_name), last_name_(last_name) {}
Person(const std::string &first_name, const char *last_name)
: first_name_(first_name), last_name_(last_name) {}
Person(const char *first_name, const std::string &last_name)
: first_name_(first_name), last_name_(last_name) {}
private:
std::string first_name_;
std::string last_name_;
};
这也太可怕了,仅仅两个参数就要写四个构造函数,难道就没有更优雅的方法?
当然有,这就是值传递并移动(pass by value and use std::move)方法。按照这个方法,只需要这么写:
class Person {
public:
Person(std::string first_name, std::string last_name)
: first_name_(std::move(first_name)), last_name_(std::move(last_name)) {}
private:
std::string first_name_;
std::string last_name_;
};
一个构造函数,统一所有情况。在所有情况下,这种写法最多只会有一次拷贝。下面来看看不同情况下都发生了什么。
当两个实参都是 std::string
时:
int main() {
std::string first_name = "klare";
std::string last_name = "neroll";
Person person(first_name, last_name);
}
构造函数的参数会通过 std::string
的拷贝构造函数构造,此时字符串被拷贝一次。
在成员初始化列表中,: first_name_(std::move(first_name)), last_name_(std::move(last_name))
通过移动构造函数初始化 person
对象的两个成员,此时不发生拷贝。因此总共只发生一次拷贝。
当两个参数都是 const char *
时,或者一个 std::string
,一个 const char *
时,情况类似,每个参数都只被拷贝一次。
这种写法的优势还不仅于此,它还可以做到零拷贝。例如下面这种情况:
int main() {
std::string first_name = "klare";
std::string last_name = "neroll";
Person person(std::move(first_name), std::move(last_name));
}
在这种情况下,构造函数的参数会通过移动构造函数构造,不发生拷贝,在成员初始化列表中,再次通过移动构造函数初始化两个成员,同样也不发生拷贝。main
函数中的字符串通过两次 std::move
,最终被移动到了 person
的成员中,整个过程不发生任何拷贝。
Pass by value and use std::move 不仅仅适用于参数为 std::string
的情况。在参数类型可拷贝、可移动,且移动的代价小于拷贝的代价时,就可以使用这种写法。在大部分场景下,这种写法都适用。
Pass by value and use std::move 是一个良好的实践,目前它已经被集成到了 clang-tidy 中,只要打开相应选项,clang-tidy 就会对能够优化的地方发出警告。在 C++ 代码中,推荐在构造函数中使用这种写法,减少不必要的拷贝,提升性能。这不是过早优化,而是良好的实践范例。