29

Previously that was std::string::c_str()'s job, but as of C++11, data() also provides it, why was c_str()'s null-terminating-character added to std::string::data()? To me it seems like a waste of CPU cycles, in cases where the null-terminating-character is not relevant at all and only data() is used, a C++03 compiler doesn't have to care about the terminator, and don't have to write 0 to the terminator every time the string is resized, but a C++11 compiler, because of the data()-null-guarantee, has to waste cycles writing 0 every time the string is resized, so since it potentially makes code slower, I guess they had some reason to add that guarantee, what was it?

David Haim
  • 25,446
  • 3
  • 44
  • 78
hanshenrik
  • 19,904
  • 4
  • 43
  • 89
  • 3
    This also concerns [operator at](https://en.cppreference.com/w/cpp/string/basic_string/operator_at) where access with `pos == size()` was previously undefined behavior, but now it has to return the null character. – Blaze Jul 02 '19 at 11:21
  • 19
    If you have enough time to use std::string to store none-string data, you should not think about runtime overhead. If you only need a vector for your data, use std::vector instead! std::string is made for string representation and as this provides additional features like the null termination. If your intention is to abuse it, don't wonder of the overhead – Klaus Jul 02 '19 at 11:28
  • 1
    @Blaze That's operator[], not at. – L. F. Jul 02 '19 at 11:35
  • The last paragraph of this answer https://stackoverflow.com/a/194654/10749452 seems to answer the question quite well. – Benjamin Bihler Jul 02 '19 at 11:37
  • 1
    @L.F. I know. I wrote "operator at" because that's what it calls it in the URL, and I'm not sure how those comment links work with text containing brackets. – Blaze Jul 02 '19 at 11:48
  • @Blaze the distinction is important however, because `at` never has UB, and it throws an exception with `pos == size()` rather than return null character. The choice of URL is quite confusing on the part of cppreference. I usually call it the subscript operator. – eerorika Jul 02 '19 at 12:09
  • 3
    @Blaze You can escape using backslashes: `[operator\[\]](https://en.cppreference.com/w/cpp/string/basic_string/operator_at)` gives [operator\[\]](https://en.cppreference.com/w/cpp/string/basic_string/operator_at) – aschepler Jul 02 '19 at 12:30
  • 1
    @Klaus excellent point. must say tho, last time i was that time-constrained, i was managing the buffer manually as i didn't have time for std::vector's null-initialization on every resize (buffer would never grow beyond 16KB but sometimes it got much smaller, like a few bytes, then it would usually go right back to being 16KB~, every time it grew back, std::vector would zero-out everything that didn't need zeroing) – hanshenrik Jul 02 '19 at 23:09
  • Do you really know that 16K is the limit? Unless you are significantly space constrained that's pretty small. Have you tested just using a static buffer? (I mean, I know there are some *very* tight environments out there, but I'd have tried the static buffer even on my mac classic with its 1 MB RAM.) – dmckee --- ex-moderator kitten Jul 03 '19 at 00:07
  • @dmckee iirc it was 4x linux kernel page size, which means 16K on x86-64, 64K on ARM64, 256K on Itanium, something like that – hanshenrik Jul 03 '19 at 10:02
  • I *thought* that change happened before C++11, like after C++98. Does anyone have a reference to the standard that specifies the behavior change? – jww Jul 03 '19 at 18:07

4 Answers4

28

There are two points to discuss here:

Space for the null-terminator

In theory a C++03 implementation could have avoided allocating space for the terminator and/or may have needed to perform copies (e.g. unsharing).

However, all sane implementations allocated room for the null-terminator in order to support c_str() to begin with, because otherwise it would be virtually unusable if that was not a trivial call.

The null-terminator itself

It is true that some very (1999), very old implementations (2001) wrote the \0 every c_str() call.

However, major implementations changed (2004) or were already like that (2010) to avoid such a thing way before C++11 was released, so when the new standard came, for many users nothing changed.

Now, whether a C++03 implementation should have done it or not:

To me it seems like a waste of CPU cycles

Not really. If you are calling c_str() more than once, you are already wasting cycles by writing it several times. Not only that, you are messing with the cache hierarchy, which is important to consider in multithreaded systems. Recall that multi-core/SMT CPUs started to appear between 2001 and 2006, which explains the switch to modern, non-CoW implementations (even if there were multi-CPU systems a couple of decades before that).

The only situation where you would save anything is if you never called c_str(). However, note that when you are re-sizing the string, you are anyway re-writing everything. An additional byte is going to be hardly measurable.

In other words, by not writing the terminator on re-size, you are exposing yourself to worse performance/latency. By writing it once at the same time you have to perform a copy of the string, the performance behavior is way more predictable and you avoid performance pitfalls if you end up using c_str(), specially on multithreaded systems.

Toby Speight
  • 27,591
  • 48
  • 66
  • 103
Acorn
  • 24,970
  • 5
  • 40
  • 69
  • most c++03 implementations probably pre-allocated and pre-initialized the null-terminator anyway, but consider `std::string str="bar";str.resize(2);std::fwrite(str.data(),str.size(),1,fp);` - a c++03 compiler does NOT have to write a null byte after `ba`, but a c++11 compiler does, this code will probably run faster with a c++03 compiler (which doesn't have to do the extra null-byte-write) – hanshenrik Jul 02 '19 at 11:17
  • 4
    "this code will probably run faster" -> measurement needed – Caleth Jul 02 '19 at 11:26
  • @darune It used to be able to. In C++11 it was changed and `c_str` must have a null and must work in constant time. It also has to be thread safe so doing a write and making it thread safe would be very costly. – NathanOliver Jul 02 '19 at 12:29
  • 1
    @Acorn The change to stdlibc++ `c_str` appears to have been a reaction to the change in the standard draft. I presume in order to support the upcoming c++0x. "Changing before C++11 was released" is not an argument that can be derived from that. – eerorika Jul 02 '19 at 13:09
  • 2
    @eerorika Yeah, the key is that people was already acknowledging the modern implementation way before C++11 (i.e. non-CoW etc.). – Acorn Jul 02 '19 at 13:22
25

Advantages of the change:

  1. When data also guarantees the null terminator, the programmer doesn't need to know obscure details of differences between c_str and data and consequently would avoid undefined behaviour from passing strings without guarantee of null termination into functions that require null termination. Such functions are ubiquitous in C interfaces, and C interfaces are used in C++ a lot.

  2. The subscript operator was also changed to allow read access to str[str.size()]. Not allowing access to str.data() + str.size() would be inconsistent.

  3. While not initialising the null terminator upon resize etc. may make that operation faster, it forces the initialisation in c_str which makes that function slower¹. The optimisation case that was removed was not universally the better choice. Given the change mentioned in point 2. that slowness would have affected the subscript operator as well, which would certainly not have been acceptable for performance. As such, the null terminator was going to be there anyway, and therefore there would not be a downside in guaranteeing that it is.

Curious detail: str.at(str.size()) still throws an exception.

P.S. There was another change, that is to guarantee that strings have contiguous storage (which is why data is provided in the first place). Prior to C++11, implementations could have used roped strings, and reallocate upon call to c_str. No major implementation had chosen to exploit this freedom (to my knowledge).

P.P.S Old versions of GCC's libstdc++ for example apparently did set the null terminator only in c_str until version 3.4. See the related commit for details.


¹ A factor to this is concurrency that was introduced to the language standard in C++11. Concurrent non-atomic modification is data-race undefined behaviour, which is why C++ compilers are allowed to optimize aggressively and keep things in registers. So a library implementation written in ordinary C++ would have UB for concurrent calls to .c_str()

In practice (see comments) having multiple threads writing the same thing wouldn't cause a correctness problem because asm for real CPUs doesn't have UB. And C++ UB rules mean that multiple threads actually modifying a std::string object (other than calling c_str()) without synchronization is something the compiler + library can assume doesn't happen.

But it would dirty cache and prevent other threads from reading it, so is still a poor choice, especially for strings that potentially have concurrent readers. Also it would stop .c_str() from basically optimizing away because of the store side-effect.

Toby Speight
  • 27,591
  • 48
  • 66
  • 103
eerorika
  • 232,697
  • 12
  • 197
  • 326
  • 1
    I think having `c_str()` write the buffer every time would just cause cache misses with multiple concurrent "readers", not require locking. Concurrent read + actual write (that could change the size or realloc the buffer) to a std::string object is still UB, so storing a terminator in already-reserved space can happen without locking. It's idempotent. In asm for real CPUs, concurrent stores of a zero byte won't change what readers actually read. (i.e. in asm there is no "data race UB"). – Peter Cordes Jul 03 '19 at 01:50
  • Implementations would probably not try to avoid C++ data-race "UB" in the library source code. It wouldn't be compiler-visible and won't cause any actual harm on a real implementation: the store would be ordered by any synchronizes-with relationships established by mutexes or acq/rel atomics. So anyway, yes, it would be garbage for performance but I think in this one case it's fine to "invent writes" when all threads will be inventing the same write of the same byte. Dirtying cache sucks, and all other cores will have to wait for access to the line before they can even read it. – Peter Cordes Jul 03 '19 at 01:54
  • It seems like a bit of a mess until you realise that, without the null, you can't tell how long the array is unless you pass around the length with the pointer. In a better world, `str.data()` would return an array with a known length (`std::vector`?). NB the null is not logically part of the string, this is why `str.at(str.size())` absolutely should throw an exception. – JeremyP Jul 03 '19 at 09:11
13

The premise of the question is problematic.

a string class has to do a lot of expansive things, like allocating dynamic memory, copying bytes from one buffer to another, freeing the underlying memory and so on.

what upsets you is one lousy mov assembly instruction? believe me, this doesn't effect your performance even by 0.5%.

When writing a programing language runtime, you can't be obsessive about every small assembly instruction. you have to choose your optimization battles wisely, and optimizing an un-noticable null termination is not one of them.

In this specific case, being compatible with C is way more important than null termination.

David Haim
  • 25,446
  • 3
  • 44
  • 78
  • 2
    While I agree with the statement about micro-optimization, C++ usually follows a "you don't pay for what you need" rule. And since we already have `c_str()` being compatible with C, I don't see how making `data()` a synonim of `c_str()` helps with anything. – Yksisarvinen Jul 02 '19 at 11:23
  • 8
    @Yksisarvinen "you don't pay for what you don't need" cannot be waved at anything. why does `std::future` have to be thread safe? I wanna use it for my mono-threaded application. make it non thread safe. I can literally go to any class in the C++ standard, find a case where my application uses **less** than what's provided and complain that I pay for what I don't use – David Haim Jul 02 '19 at 11:26
  • 3
    @DavidHaim `std::shared_ptr` is a better example of adding thread-safety costs to `std` – Caleth Jul 02 '19 at 11:27
  • @DavidHaim Sure, but this is not the same case. If there was a class called `std::non_thread_safe_future` and `std::future`, and suddenly the standard decides that `std::non_thread_safe_future` should be a typedef for `std::future`, then it would be similar to the question here. I also didn't say that "you don't pay for what you don't need" is a categorical imperative in C++. I said that it's usually applied, and the C compatibility is not a reason for dismissing it, because we already had a C-compatible method. – Yksisarvinen Jul 02 '19 at 11:38
  • `being compatible with C is way more important than null termination. ` I don't quite understand what this means. – eerorika Jul 02 '19 at 11:55
  • 2
    The C++ mantra is to stay compatible with C (at least at ABI and calling-convention level) when possible – Basile Starynkevitch Jul 02 '19 at 12:56
  • @BasileStarynkevitch But what does a change in C++ standard library template have anything to do with compatibility with C? And in which case is null-termination contradictory to compatibility with C? And if it is not contradictory, then what is the point of comparing their importance? Furthermore, given that this change guarantees null-termination, if it were contradictory to compatibility with C, then how is the importance of C compatibility an argument in favour of the change? – eerorika Jul 02 '19 at 13:04
  • Don't forget that the CPU reads words, not bytes, so in 3/4 cases on 32-bit or 7/8 cases on 64-bit, no extra read is required. – grahamj42 Jul 02 '19 at 22:57
  • 1
    @Yksisarvinen: given that `c_str()` needs to exist and needs to be efficient, including for concurrent readers, we might as well make `data()` equivalent to it. This answer makes some good points, but doesn't make that point as well as the others, though. There are still C++ implementations for single-core systems like microcontrollers, and it sounds reasonable on the surface to let an implementation optimize away the 0 terminator. But that's still maybe doable by the as-if rule: with whole program optimization, a hypothetical C++ compiler could notice that some strings never used c_str().. – Peter Cordes Jul 03 '19 at 02:12
  • @BasileStarynkevitch `std::string` is already "not compatible with C" in a sense. it allows for embedded null characters. – Dan M. Jul 03 '19 at 11:19
2

Actually, it's the other way around.

Before C++11, c_str() may in theory have cost "additional cycles" as well as a copy, so as to ensure the presence of a null terminator at the end of the buffer.

This was unfortunate, particularly as it can be fixed very simply, with effectively no additional runtime cost, by simply incorporating a null byte at the end of every buffer to begin with. Only one additional byte to allocate (and a teensie little write), with no runtime cost at point of use, in exchange for thread-safety and a boatload of sanity.

Once you've done that, c_str() is literally the same as data() by definition. So, the "change" to data() actually came for free. Nobody's adding an extra byte to the result of data(); it's already there.

Helping matters is the fact that most implementations already did this under C++03 anyway, to avoid the hypothetical runtime cost ascribed to c_str().

So, in short, this has almost certainly cost you literally nothing.

Lightness Races in Orbit
  • 378,754
  • 76
  • 643
  • 1,055