Document number: PxxxxR0
Date: 2017-03-07
Project: ISO JTC1/SC22/WG21, Programming Language C++
Audience: Library Evolution Working Group
Reply to: Arthur O'Dwyer <arthur.j.odwyer@gmail.com>

Moving out of ostringstream

1. Introduction
2. Motivations
3. Implementation experience
4. Performance
5. Proposed wording
    27.8.2 Class template basic_stringbuf [stringbuf]
    27.8.2.3 Member functions [stringbuf.members]
    27.8.4 Class template basic_ostringstream [ostringstream]
    27.8.4.3 Member functions [ostringstream.members]
6. References

1. Introduction

"Iostreams" are often (rightly) maligned for being inefficient, for dragging in locales, for taking up a lot of heap allocations, and so on. This is too bad, as otherwise they're still very useful for simple string formatting.

This paper proposes to eliminate just the most minor of the issues with ostringstream, namely, that .str() always makes an extra copy of the contained data.

Benchmarks indicate that this optimization doesn't matter in practice, and so this proposal probably ought not to be adopted, unless some other motivation for it can be found.

2. Motivations

Here are a few examples of ostringstream usage from a production codebase (slightly anonymized):

    std::string get_parameters(boost::property_tree::ptree params)
    {
        std::ostringstream oss;
        oss << '[';
        boost::property_tree::json_parser::write_json(oss, params, false);
        oss << ']';
        return oss.str();
    }

    std::string get_path(const std::string& prefix)
    {
        std::ostringstream path;
        path << get_cache_dir() << '/' << prefix << '-'
             << std::any_cast<int>(params.at("project_id")) << '-'
             << std::any_cast<const std::string&>(params.at("session_id")) << '-'
             << compute_cache_key(params);
        return path.str();
    }

    void validate_id(size_t server_id)
    {
        // ...
        if (server_id < 0 || server_id >= servers.size()) {
            std::ostringstream oss;
            oss << "invalid server_id: " << server_id;
            throw std::runtime_error(oss.str());
        }
        // ...
    }

In the first case, we're using boost::property_tree::json_parser::write_json, which has "write to file by name" and "write to ostream" overloads but no other alternative (in particular, no "write to std::string" overload). This code cannot reasonably be rewritten to avoid the use of iostreams.

In the latter two cases, the use of iostreams pessimizes the code, but in exchange the programmer gets a bit of convenience. Consider the non-iostreams versions of the latter code:

    std::string get_path(const std::string& prefix)
    {
        std::string path;
        path = get_cache_dir() + "/" + prefix + '-'
             + std::to_string(std::any_cast<int>(params.at("project_id"))) + '-'
             + std::any_cast<const std::string&>(params.at("session_id")) + '-'
             + compute_cache_key(params);
        return path;
    }

    void validate_id(size_t server_id)
    {
        // ...
        if (server_id < 0 || server_id >= servers.size()) {
            throw std::runtime_error("invalid server_id: "s + std::to_string(server_id));
        }
        // ...
    }

Notice the careful usage of suffix s and std::to_string in the latter example. We have observed naive or careless programmers in the wild writing the equivalent of

        if (server_id < 0 || server_id >= servers.size()) {
            throw std::runtime_error("invalid server_id: " + server_id);
        }
(which is likely to segfault if server_id > strlen("invalid server_id: ")). Anything we can do to keep programmers away from the manual labor of string conversions is a good thing.

As this third example illustrates, programmers will use ostringstream for string formatting even in cases where avoiding its use would be "easy" — because once you learn how simple it is to use ostringstream, why not be consistent and use it everywhere? This is how we ended up with the above code's three-line throw where one slightly-more-complex line would have sufficed. Simplicity and consistency win every time.

The inefficiency of ostringstream for these use-cases comes from three orthogonal issues:

This paper deals only with the last issue.

In short, I propose to alter the meaning of the following well-formed code so that the implementation is no longer required to return a copy of the underlying string buffer, but rather may choose to return the move of the underlying buffer.

    std::string get_parameters(boost::property_tree::ptree params)
    {
        std::ostringstream oss;
        oss << '[';
        boost::property_tree::json_parser::write_json(oss, params, false);
        oss << ']';
        return std::move(oss).str();
    }

    std::string get_path(const std::string& prefix)
    {
        std::ostringstream path;
        path << get_cache_dir() << '/' << prefix << '-'
             << std::any_cast<int>(params.at("project_id")) << '-'
             << std::any_cast<const std::string&>(params.at("session_id")) << '-'
             << compute_cache_key(params);
        return std::move(path).str();
    }

    void validate_id(size_t server_id)
    {
        // ...
        if (server_id < 0 || server_id >= servers.size()) {
            std::ostringstream oss;
            oss << "invalid server_id: " << server_id;
            throw std::runtime_error(std::move(oss).str());
        }
        // ...
    }

Looking forward to C++20+, we might predict that the move-semantics of return statements will continue evolving to the point where we won't even need to specify std::move(oss) anymore, because the compiler will be able to see that oss's lifetime is ending and accordingly perform the overload resolution of oss.str() as if oss were already an rvalue. However, that's a subject for another, much more ambitious paper.

3. Implementation experience

I've looked at what it would take to provide the above semantics in libc++. Fundamentally, we need to add an overload basic_ostringstream::str()&&. libc++ implements basic_ostringstream::str() entirely in terms of basic_stringbuf::str(), so we'll need an overload of basic_stringbuf::str()&& as well.

Since str() is not a virtual function, there's no specific need to provide the new overloads for basic_istringstream and basic_stringstream as well; but it seems like a good idea to provide them, for consistency.

So I propose the following additional member function overloads:

The libc++ implementation of basic_stringbuf::str()&& looks like this:

    template <class _CharT, class _Traits, class _Allocator>
    basic_string<_CharT, _Traits, _Allocator>
    basic_stringbuf<_CharT, _Traits, _Allocator>::str() const &
    {
        if (__mode_ & ios_base::out)
        {
            if (__hm_ < this->pptr())
                __hm_ = this->pptr();
            return string_type(this->pbase(), __hm_, __str_.get_allocator());
        }
        else if (__mode_ & ios_base::in)
            return string_type(this->eback(), this->egptr(), __str_.get_allocator());
        return string_type(__str_.get_allocator());
    }

    template <class _CharT, class _Traits, class _Allocator>
    basic_string<_CharT, _Traits, _Allocator>
    basic_stringbuf<_CharT, _Traits, _Allocator>::str() &&
    {
        if (__mode_ & ios_base::out)
        {
            if (__hm_ < this->pptr())
                __hm_ = this->pptr();
            ptrdiff_t __hm = __hm_ - this->pbase();
            string_type s = _VSTD::move(__str_);
            s.resize(__hm);
            return s;
        }
        else if (__mode_ & ios_base::in)
            return string_type(this->eback(), this->egptr(), __str_.get_allocator());
        return string_type(__str_.get_allocator());
    }

There is also an overload basic_ostringstream::str(const string&) that sets the contents of the underlying string buffer. It would make sense to provide move-enabled versions of this member function for all the above classes as well; but this could be done either by adding an overload (taking string&&) or by rewriting the signature of the existing function (to take a string by value and move-out-of it into the underlying buffer). Since this is potentially controversial and not in the "critical path" of this optimization, I'm not proposing it.

4. Performance benchmarks

As it turns out, any performance improvement from the above patch is completely lost in the noise of operator<<. Here are my test cases:

std::string trivial(int n)
{
    std::ostringstream oss;
    oss << "abcdefghijklmnopqrstuvwxyz";
    return std::move(oss).str();
}

std::string char_conversion(int n)
{
    std::ostringstream oss;
    for (int i=0; i < 1000; ++i) {
        oss << "[" << char('A' + i%32) << "], ";
    }
    return std::move(oss).str();
}

std::string int_conversion(int n)
{
    std::ostringstream oss;
    for (int i=0; i < 1000; ++i) {
        oss << "[" << (i%32) << "], ";
    }
    return std::move(oss).str();
}

And here are some outputs of sudo perf top while those functions were running in tight loops, using the existing ("old") C++11 library and using the proposed ("new") move-enabled library. It appears that there is no difference between the profiles, because the lion's share of time is spent in low-level formatting functions such as xsputn.

This benchmark result strongly suggests that "performance" is not an appropriate reason to adopt this proposal.

(trivial, old library)
 15.82%  libstdc++.so.6.0.19  [.] __dynamic_cast
  6.18%  libstdc++.so.6.0.19  [.] __cxxabiv1::__si_class_type_info::__do_dyncast(long, __cxxabiv1::__class_type_info::__sub_kind, __cxxabiv1::__class_type_inf
  5.71%  libc-2.19.so         [.] _int_free
  5.15%  libstdc++.so.6.0.19  [.] __cxxabiv1::__vmi_class_type_info::__do_dyncast(long, __cxxabiv1::__class_type_info::__sub_kind, __cxxabiv1::__class_type_in
  4.98%  libc-2.19.so         [.] __GI___strcmp_ssse3
  4.72%  new                  [.] trivial(int)
  3.46%  libc-2.19.so         [.] _int_malloc
  2.66%  libstdc++.so.6.0.19  [.] std::locale::locale()
  2.62%  libc-2.19.so         [.] malloc
  2.46%  libstdc++.so.6.0.19  [.] std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<c
  2.13%  libstdc++.so.6.0.19  [.] std::basic_stringbuf<char, std::char_traits<char>, std::allocator<char> >::overflow(int)
  2.12%  libstdc++.so.6.0.19  [.] std::locale::operator=(std::locale const&)
  1.94%  libstdc++.so.6.0.19  [.] std::ios_base::ios_base()
  1.81%  libstdc++.so.6.0.19  [.] std::locale::~locale()
  1.73%  libstdc++.so.6.0.19  [.] std::locale::id::_M_id() const
  1.48%  libstdc++.so.6.0.19  [.] bool std::has_facet<std::ctype<char> >(std::locale const&)
  1.43%  libstdc++.so.6.0.19  [.] std::basic_streambuf<char, std::char_traits<char> >::xsputn(char const*, long)
  1.23%  libc-2.19.so         [.] __memcpy_sse2_unaligned

(trivial, new library)
 16.53%  libstdc++.so.6.0.19       [.] __dynamic_cast
  6.28%  libstdc++.so.6.0.19       [.] __cxxabiv1::__si_class_type_info::__do_dyncast(long, __cxxabiv1::__class_type_info::__sub_kind, __cxxabiv1::__class_typ
  6.12%  libc-2.19.so              [.] _int_free
  5.40%  libstdc++.so.6.0.19       [.] __cxxabiv1::__vmi_class_type_info::__do_dyncast(long, __cxxabiv1::__class_type_info::__sub_kind, __cxxabiv1::__class_ty
  5.05%  libc-2.19.so              [.] __GI___strcmp_ssse3
  4.82%  old                       [.] trivial(int)
  3.60%  libc-2.19.so              [.] _int_malloc
  2.80%  libstdc++.so.6.0.19       [.] std::locale::locale()
  2.75%  libc-2.19.so              [.] malloc
  2.43%  libstdc++.so.6.0.19       [.] std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostr
  2.30%  libstdc++.so.6.0.19       [.] std::basic_stringbuf<char, std::char_traits<char>, std::allocator<char> >::overflow(int)
  2.25%  libstdc++.so.6.0.19       [.] std::locale::operator=(std::locale const&)
  1.88%  libstdc++.so.6.0.19       [.] std::locale::id::_M_id() const
  1.87%  libstdc++.so.6.0.19       [.] std::ios_base::ios_base()
  1.85%  libstdc++.so.6.0.19       [.] std::locale::~locale()
  1.53%  libstdc++.so.6.0.19       [.] bool std::has_facet<std::ctype<char> >(std::locale const&)
  1.49%  libstdc++.so.6.0.19       [.] std::basic_streambuf<char, std::char_traits<char> >::xsputn(char const*, long)
  1.23%  libc-2.19.so              [.] __memcpy_sse2_unaligned

(char_conversion, old library)
 42.08%  libstdc++.so.6.0.19       [.] std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostr
 23.83%  libstdc++.so.6.0.19       [.] std::basic_streambuf<char, std::char_traits<char> >::xsputn(char const*, long)
 10.72%  libstdc++.so.6.0.19       [.] std::ostream::sentry::sentry(std::ostream&)
  8.36%  libc-2.19.so              [.] __memcpy_sse2_unaligned
  2.45%  old                       [.] char_conversion(int)
  1.83%  libstdc++.so.6.0.19       [.] _ZNSo6sentryC1ERSo@plt
  1.74%  libstdc++.so.6.0.19       [.] memcpy@plt
  1.17%  old                       [.] _ZSt16__ostream_insertIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_PKS3_l@plt

(char_conversion, new library)
 41.94%  libstdc++.so.6.0.19  [.] std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream >::xsputn(char const*, long)
 10.75%  libstdc++.so.6.0.19  [.] std::ostream::sentry::sentry(std::ostream&)
  8.30%  libc-2.19.so         [.] __memcpy_sse2_unaligned
  2.50%  new                  [.] char_conversion(int)
  1.82%  libstdc++.so.6.0.19  [.] memcpy@plt
  1.81%  libstdc++.so.6.0.19  [.] _ZNSo6sentryC1ERSo@plt
  1.18%  new                  [.] _ZSt16__ostream_insertIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_PKS3_l@plt

(int_conversion, old library)
 19.36%  libstdc++.so.6.0.19    [.] std::basic_ostream<char, std::char_traits >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream
 16.26%  libstdc++.so.6.0.19    [.] std::basic_streambuf<char, std::char_traits >::xsputn(char const*, long)
 15.06%  libstdc++.so.6.0.19    [.] std::ostream& std::ostream::_M_insert(long)
 11.24%  libstdc++.so.6.0.19    [.] std::ostreambuf_iterator<char, std::char_traits > std::num_put<char, std::ostreambuf_iterator<char, std::char_traits
 10.33%  libstdc++.so.6.0.19    [.] std::ostream::sentry::sentry(std::ostream&)
  6.49%  libc-2.19.so           [.] __memcpy_sse2_unaligned
  4.54%  libstdc++.so.6.0.19    [.] 0x0000000000088bc9
  2.30%  libstdc++.so.6.0.19    [.] std::num_put<char, std::ostreambuf_iterator<char, std::char_traits<char> > >::do_put(std::ostreambuf_iterator<char, std::c
  1.69%  old                    [.] int_conversion(int)
  1.25%  libstdc++.so.6.0.19    [.] std::ostream::operator<<(int)
  1.14%  libstdc++.so.6.0.19    [.] memcpy@plt

(int_conversion, new library)
 19.79%  libstdc++.so.6.0.19           [.] std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_
 16.14%  libstdc++.so.6.0.19           [.] std::basic_streambuf<char, std::char_traits<char> >::xsputn(char const*, long)
 15.04%  libstdc++.so.6.0.19           [.] std::ostream& std::ostream::_M_insert<long>(long)
 11.71%  libstdc++.so.6.0.19           [.] std::ostreambuf_iterator<char, std::char_traits<char> > std::num_put<char, std::ostreambuf_iterator<char, std::char
 10.55%  libstdc++.so.6.0.19           [.] std::ostream::sentry::sentry(std::ostream&)
  6.23%  libc-2.19.so                  [.] __memcpy_sse2_unaligned
  4.68%  libstdc++.so.6.0.19           [.] 0x0000000000088b9b
  2.30%  libstdc++.so.6.0.19           [.] std::num_put<char, std::ostreambuf_iterator<char, std::char_traits<char> > >::do_put(std::ostreambuf_iterator<char,
  1.74%  new                           [.] int_conversion(int)
  1.42%  libstdc++.so.6.0.19           [.] std::ostream::operator<<(int)
  1.07%  libstdc++.so.6.0.19           [.] memcpy@plt

5. Proposed wording for C++20

The wording in this section is relative to WG21 draft N4618 [N4618], that is, the current draft of the C++17 standard.

27.8.2 Class template basic_stringbuf [stringbuf]

Edit paragraph 1 as follows.

// 27.8.2.3, get and set
basic_string<charT, traits, Allocator> str() const &;
basic_string<charT, traits, Allocator> str() &&;
void str(const basic_string<charT, traits, Allocator>& s);

27.8.2.3 Member functions [stringbuf.members]

Edit paragraph 1 as follows.

basic_string<charT, traits, Allocator> str() const &;
basic_string<charT, traits, Allocator> str() &&;

Returns: A basic_string object whose content is equal to the basic_stringbuf underlying character sequence. If the basic_stringbuf was created only in input mode, the resultant basic_string contains the character sequence in the range [eback(), egptr()). If the basic_stringbuf was created with which & ios_base::out being nonzero then the resultant basic_string contains the character sequence in the range [pbase(), high_mark), where high_mark represents the position one past the highest initialized character in the buffer. Characters can be initialized by writing to the stream, by constructing the basic_stringbuf with a basic_string, or by calling the str(basic_string) member function. In the case of calling the str(basic_string) member function, all characters initialized prior to the call are now considered uninitialized (except for those characters re-initialized by the new basic_string). Otherwise the basic_stringbuf has been created in neither input nor output mode and a zero length basic_string is returned.

After calling the rvalue-reference-qualified str() member function, the contents of the basic_stringbuf underlying character sequence are unspecified.

27.8.4 Class template basic_ostringstream [ostringstream]

Edit paragraph 1 as follows.

// 27.8.4.3, members
basic_stringbuf<charT, traits, Allocator>* rdbuf() const;

basic_string<charT, traits, Allocator> str() const &;
basic_string<charT, traits, Allocator> str() &&;
void str(const basic_string<charT, traits, Allocator>& s);

27.8.4.3 Member functions [ostringstream.members]

Edit paragraph 2, and add a paragraph between paragraphs 2 and 3, as follows.

basic_string<charT, traits, Allocator> str() const &;

Returns: rdbuf()->str().

basic_string<charT, traits, Allocator> str() &&;

Returns: std::move(rdbuf())->str().

6. References

[N4618]
"Working Draft, Standard for Programming Language C++" (Nov 2016).
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2016/n4618.pdf.