在其他语言里,字符串拼接可能是一个常见而且基本不会去注意的部分,但是在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 即可订阅《并发与分布式系统研究》