在其他语言里,字符串拼接可能是一个常见而且基本不会去注意的部分,但是在C++中字符串拼接有非常多的解决方法。造成这种现象的原因是,C++程序员想要高效地拼接字符串。
比如说下面的代码
std::string concat_string(const std::string& name, const std::string& domain) {
return name + '@' + domain;
}
对于有非C/C++语言的人来说可能最平常不过的代码,C++程序员可能直觉上不会采用这种写法。那么C++里面该用什么写法呢?或者说最佳实践是什么?
这里不会列举各种字符串拼接的方式,如果你有兴趣可以在StackOverflow上搜搜看。个人想要说的是:在分析了C++11里字符串的操作之后个人给出的结论:C++11里最佳的字符串拼接其实就是上述写法。以下是具体分析。
首先给出一个模拟std::string的MyString类。
class MyString {
public:
MyString(const char *chars) {
std::cout << "MyString(char[])\n";
length_ = std::strlen(chars);
char *chars_copy = new char[length_ + 1];
std::copy(chars, chars + length_ + 1, chars_copy);
chars_ = chars_copy;
}
explicit MyString(const char *chars, size_t length) {
std::cout << "MyString(char[], int)\n";
length_ = length;
char *chars_copy = new char[length_ + 1];
std::copy(chars, chars + length_ + 1, chars_copy);
chars_ = chars_copy;
}
// copy
MyString(const MyString &string) {
std::cout << "MyString(copy)\n";
length_ = string.length_;
char *chars_copy = new char[string.length_ + 1];
std::copy(string.chars_, string.chars_ + length_ + 1, chars_copy);
chars_ = chars_copy;
}
// copy assignment
MyString &operator=(const MyString &) = delete;
// move
MyString(MyString &&string) noexcept {
std::cout << "MyString(move), " << string.chars_ << std::endl;
length_ = string.length_;
chars_ = string.chars_;
string.length_ = 0;
string.chars_ = nullptr;
}
// move assignment
MyString &operator=(const MyString &&) = delete;
int length() const { return length_; }
MyString &operator+=(const char *chars) {
return append(chars);
}
MyString &operator+=(const MyString &string) {
return append(string.chars_);
}
MyString &operator+=(const char ch) {
std::cout << "MyString#operator+=(char)\n";
char *new_chars = new char[length_ + 2];
std::copy(chars_, chars_ + length_, new_chars);
new_chars[length_] = ch;
new_chars[length_ + 1] = '\0';
length_ += 1;
delete[] chars_;
chars_ = new_chars;
return *this;
}
MyString &append(const char *chars) {
std::cout << "MyString#append(const char[])\n";
size_t length = std::strlen(chars);
char *new_chars = new char[length_ + length + 1];
std::copy(chars_, chars_ + length_, new_chars);
std::copy(chars, chars + length + 1, new_chars + length_);
length_ += length;
delete[] chars_;
chars_ = new_chars;
return *this;
}
friend std::ostream &operator<<(std::ostream &os, const MyString &string) {
if (string.chars_ != nullptr) {
os << string.chars_;
}
return os;
}
~MyString() {
std::cout << "~MyString(";
if (chars_ != nullptr) {
std::cout << chars_;
}
std::cout << ")\n";
delete[] chars_;
}
private:
char *chars_;
size_t length_;
};
MyString类支持copy/move,以及重载了+=操作符。
在MyString的方法实现里面,加了部分debug代码,可以让你理解字符串拼接时实际哪些方法被调用了。
对其他语言背景的人来说,需要知道std::string其实比实际内容多留了一些空间方便追加字符,所以std::string是可变而且空间利用率不是100%。
这里MyString并没有像std::string一样留一些空间,不过这不影响分析。
调用代码如下
int main() {
MyString email = concat_string(MyString{"foo"}, MyString{"bar.com"});
MyString name{"foo"};
MyString email2 = concat_string(name, MyString{"bar.com"});
return 0;
}
如果你想在尝试编译的话,肯定是无法通过的,因为操作符+没有被重载。以下是最基本的操作符重载。
方法前带有friend,所以请写在MyString类里面。
friend MyString operator+(const MyString &lhs, const MyString &rhs) {
std::cout << "MyString+(const MyString&, const MyString&)\n";
MyString result{lhs};
result += rhs.chars_;
return result;
}
friend MyString operator+(const MyString &lhs, const char ch) {
std::cout << "MyString+(const MyString&, const char)\n";
MyString result{lhs};
result += ch;
return result;
}
friend MyString operator+(const MyString &lhs, const char *chars) {
std::cout << "MyString+(const MyString&, const char*)\n";
MyString result{lhs};
result += chars;
return result;
}
可以看到上述代码,都有一个result的变量。因为输入参数都是const,无法修改。
这里假如你不给参数加const,用临时变量调用的代码无法编译通过,如果干脆const和引用&都不加的话,第二个用栈上变量调用的代码会复制name的内容,这可能不是你想要的。所以方法签名是const T&,代码中也必须执行一次到result复制。
这时编译并执行后得到的第一个调用的输出
MyString(char[]) MyString(char[]) MyString+(const MyString&, const char) MyString(copy) MyString#append(const char[]) MyString+(const MyString&, const MyString&) MyString(copy) MyString#append(const char[]) ~MyString(foo@) ~MyString(bar.com) ~MyString(foo) ~MyString(foo@bar.com)
可以看到copy了两次。理论上这是正确的,因为你+了两次,产生了两个临时result变量。
个人认为,因为上述原因,很多C++程序员可能不会选择开篇的那种写法。从效率上来说,最理想的状态是,只开辟一个result变量,所有字符串都往result拼接。所以产生了如下的几种写法
void concat_string6(const MyString &name, const MyString &domain, MyString &result) {
result += name;
result += '@';
result += domain;
}
MyString concat_string7(const MyString &name, const MyString &domain) {
MyString result{""};
result += name;
result += '@';
result += domain;
return result;
}
老实说这两种写法没有太大区别。虽然效率可能比较高,但是写法不直观。那么有没有其他的方法呢?
如果你仔细观察C++11引入的新的std::string对应操作符+重载的方法签名的话,你可能会发现几个带有T&&的方法签名
template< class CharT, class Traits, class Alloc >
basic_string<CharT,Traits,Alloc>
operator+( basic_string<CharT,Traits,Alloc>&& lhs,
basic_string<CharT,Traits,Alloc>&& rhs );
(6) (since C++11)
template< class CharT, class Traits, class Alloc >
basic_string<CharT,Traits,Alloc>
operator+( basic_string<CharT,Traits,Alloc>&& lhs,
const basic_string<CharT,Traits,Alloc>& rhs );
(7) (since C++11)
template< class CharT, class Traits, class Alloc >
basic_string<CharT,Traits,Alloc>
operator+( basic_string<CharT,Traits,Alloc>&& lhs,
const CharT* rhs );
(8) (since C++11)
template< class CharT, class Traits, class Alloc >
basic_string<CharT,Traits,Alloc>
operator+( basic_string<CharT,Traits,Alloc>&& lhs,
CharT rhs );
(9) (since C++11)
template< class CharT, class Traits, class Alloc >
basic_string<CharT,Traits,Alloc>
operator+( const basic_string<CharT,Traits,Alloc>& lhs,
basic_string<CharT,Traits,Alloc>&& rhs );
(10) (since C++11)
template< class CharT, class Traits, class Alloc >
basic_string<CharT,Traits,Alloc>
operator+(const CharT* lhs,
basic_string<CharT,Traits,Alloc>&& rhs );
(11) (since C++11)
template< class CharT, class Traits, class Alloc >
basic_string<CharT,Traits,Alloc>
operator+( CharT lhs,
basic_string<CharT,Traits,Alloc>&& rhs );
(12) (since C++11)
这些方法签名会带来哪些变化呢?第一个就应该是lhs可以不是const了,也就是说lhs可以修改了!以下是模拟代码(注意返回时必须使用std::move,否则会变成复制。如果有怀疑的话可以看std::string中的源码)
friend MyString operator+(MyString &&lhs, const char *chars) {
std::cout << "MyString+(MyString&&, const char*)\n";
lhs += chars;
return std::move(lhs);
}
friend MyString operator+(MyString &&lhs, const MyString &rhs) {
std::cout << "MyString+(const MyString&, const MyString&)\n";
lhs += rhs.chars_;
return std::move(lhs);
}
friend MyString operator+(MyString &&lhs, const char ch) {
std::cout << "MyString+(MyString&&, char)\n";
lhs += ch;
return std::move(lhs);
}
可以看到result变量消失了,所有方法都在修改lhs。那么这是否就意味着传入的参数会直接被修改呢?比如调用代码中的第二种方式。
答案是不会,因为在concat_string方法里name是const String&,所以匹配的是旧方法,而不是新加的这几个方法。
实际执行调用代码(第一种和第二种结果一样)
MyString(char[]) MyString(char[]) MyString+(const MyString&, const char) MyString(copy) MyString#operator+=(char) MyString+(MyString&&, const MyString&) MyString#append(const char[]) MyString(move) ~MyString() ~MyString(bar.com) ~MyString(foo@bar.com) ~MyString(foo)
可以很清晰地看到,新方法在第二个+的时候被调用了。又由于新方法中不会复制,所以是一次copy加一次move。
对比一下,不通过concat_string而是直接调用的方式
// MyString email = MyString{"foo"} + '@' + MyString{"bar.com"};
MyString(char[])
MyString+(MyString&&, char)
MyString#operator+=(char)
MyString(move)
MyString(char[])
MyString+(MyString&&, const MyString&)
MyString#append(const char[])
MyString(move)
~MyString(bar.com)
~MyString()
~MyString()
~MyString(foo@bar.com)
临时变量的话,两次move。
// MyString foo = MyString{"foo"};
// MyString email = foo + '@' + MyString{"bar.com"};
MyString(char[])
MyString+(const MyString&, const char)
MyString(copy)
MyString#operator+=(char)
MyString(char[])
MyString+(MyString&&, const MyString&)
MyString#append(const char[])
MyString(move)
~MyString(bar.com)
~MyString()
~MyString(foo@bar.com)
~MyString(foo)
一次copy,一次move。
小结一下,在C++11中,字符串的拼接支持了move,减少了copy的次数。
如果你要问是否可以不copy?那么请考虑下,C++11之前没有copy的那两种写法,即“创建一个result中间变量加上第一次字符串拼接”与“用第一个字符串复制构造一个中间变量”其实本质上没有区别,重要的是中间没有重复创建result变量就行。
作为参考,其他几种可能的写法
// copy, move, best
MyString concat_string(const MyString &name, const MyString &domain) {
return name + "@" + domain;
}
// move, move, move
MyString concat_string2(const MyString &name, const MyString &domain) {
return MyString{""} + name + "@" + domain;
}
// move, move
MyString concat_string3(MyString &&name, const MyString &domain) {
return std::move(name) + "@" + domain;
}
// move
MyString concat_string4(MyString &&name, const MyString &domain) {
name += "@";
name += domain;
return std::move(name);
}
// copy
MyString concat_string5(MyString &&name, const MyString &domain) {
name += "@";
name += domain;
return name;
}
// no copy, no move
void concat_string6(const MyString &name, const MyString &domain, MyString &result) {
result += name;
result += "@";
result += domain;
}
// no copy, no move
MyString concat_string7(const MyString &name, const MyString &domain) {
MyString result{""};
result += name;
result += "@";
result += domain;
return result;
}
效率上没有太大区别,但是相信你也会认为第一种是最好的。
最后,C++11增加的字符串拼接中,包含了以下两个方法
template< class CharT, class Traits, class Alloc >
basic_string<CharT,Traits,Alloc>
operator+(const CharT* lhs,
basic_string<CharT,Traits,Alloc>&& rhs );
(11) (since C++11)
template< class CharT, class Traits, class Alloc >
basic_string<CharT,Traits,Alloc>
operator+( CharT lhs,
basic_string<CharT,Traits,Alloc>&& rhs );
(12) (since C++11)
也就是说,你可以这么写代码
"foo" + std::to_string(123); 'f' + std::to_string(123);
而不用显示的去用const char*去构造一个std::string。
总结
字符串拼接是开发中常见的代码,所以这种基础代码中在C++11中能按照直觉写而不用担心效率着实是一件很好的事情,有一种写了那么长时间便扭的C++代码回到了原点的感觉。最后希望我的分析对各位有用。
One response to “【C++11】字符串拼接之回归原点”
感谢分享!已推荐到《开发者头条》:https://toutiao.io/posts/q6l8cu 欢迎点赞支持!使用开发者头条 App 搜索 385148 即可订阅《并发与分布式系统研究》