【C++11】字符串拼接之回归原点


在其他语言里,字符串拼接可能是一个常见而且基本不会去注意的部分,但是在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】字符串拼接之回归原点”

  1. 感谢分享!已推荐到《开发者头条》:https://toutiao.io/posts/q6l8cu 欢迎点赞支持!使用开发者头条 App 搜索 385148 即可订阅《并发与分布式系统研究》