Overview:
I found one bug.
Your move constructor puts the object into an invalid state, where you can then potentially invoke undefined behavior.
You have a small memory leak in the operator+
.
Couple of places where you are using non-standard techniques.
- Check for self assignment.
- Not using copy and swap
- Missing swap method
- Not using initializer lists.
None of it terribly bad. But as a result makes your code longer and thus harder to parse.
Other people have mentioned that you can't assume that a string does not include a null character. So using the C libraries strcpy() and strcat() are not safe. Prefer to use std::copy()
.
Also using throwing operation when you have put the object into an invalid state is not safe.
Standard technique is:
- Do all throwing operations first
- Modify state with only non throwing operations.
- Release any resources.
Code Review:
Don't see why you need special code here. Use the ability to chain constructors to simplify this and make sure that you have one place that does the allocation:
String() : data(new char[1]{'\0'}), size(0) {}
I would write like this:
String() : String(""). // or even nullptr are you support the check // for null in that constructor. {}
Why only output?
friend std::ostream& operator<<(std::ostream& os, const String& str);
Normally you want to support both input and output!
Sure. That will work.
char* data; std::size_t size;
But any change in size is now going to result in a resize. Might be nice to be able to remove then add data without reallocating memory all the time.
Note: I say might. You will have to look at the use of your String
and see if that is worth the extra variable.
I always prefer to use the initializer list to initialize the variables.
String::String(const char* str) { if (str) { size = std::strlen(str); data = new char[size + 1]; std::strcpy(data, str); } else { data = new char[1]; data[0] = '\0'; size = 0; } }
I would write like this:
String::String(char const* str) : size(str ? strlen(str), 0) , data(new char[size + 1]) // Note you will need to swap the order of the member // variables { std::copy(str, str + size, data); data[size] = '\0'; }
Again I prefer to always use the initializer list.
String::String(const String& other) { size = other.size; data = new char[size + 1]; std::strcpy(data, other.data); }
SO I would write like this:
String::String(const String& other) : size(other.size) , data(new char[size + 1]) { std::copy(other.data, other.data + size + 1, data); }
You can write this simpler using std::exchange
.
String::String(String&& other) noexcept : data(other.data), size(other.size) { other.data = nullptr; other.size = 0; }
Write like this:
String::String(String&& other) noexcept : size(std::exchange(other.size, 0)) , data(std::exchange(other.data, nullptr)) {}
I would point out that you just put the other
object into a state that has a nullptr
. This is not done in any of your constructors. I would assume that this is not a valid state for a normal object. This looks like a bug to me!
If I did this:
String one("One"); String sizeOne = std::move(One); char last = one[0]; // Would you expect that to crash?
I would consider this bad practice:
if (this == &other) { return *this; }
Yes; you do have to write in a way that works for self assignment. But this is self pessimizing code. The case that practically never happens is probably going to cost extra.
The standard technique has been, for many years now, a technique called copy and swap. This does not use a test for self assignment.
There is a standard technique for writing the copy and move assignment operator as a single function.
String& String::operator=(const String& other) { if (this == &other) { return *this; } delete[] data; size = other.size; data = new char[size + 1]; // Throwing operation. // done after modifying state. // object not in a consistent state. std::strcpy(data, other.data); return *this; } String& String::operator=(String&& other) noexcept { if (this == &other) { return *this; } delete[] data; // Not an issue with char // But you can't assume that // delete is always safe to call (think about this situation if you were building a vector rather than string). data = other.data; size = other.size; other.data = nullptr; other.size = 0; return *this; }
I would write like this (supports copy and move)
// Notice parameter is passed by value. // L-Value input is copied. // R-Value input is moved. String& String::operator=(String other) noexcept { swap(other); return *this; }
Note: If you use assignment with an L-Value it is copied into the parameter other
then state is swapped. This is the same cost as you have above. If you do assignment with R-Value then it is moved into the parameter (no copy) then state is swapped. This is the same cost as you have above. You do have to write swap()
but that is relatively trivial.
It is common to write operator+
in terms of operator+=
:
String String::operator+(const String& other) { String newStr; // Note this allocated memory. // You are overwritting "data" // Without releasing this memory. // So you have a memory leak. newStr.size = size + other.size; newStr.data = new char[newStr.size + 1]; std::strcpy(newStr.data, data); std::strcat(newStr.data, other.data); return newStr; }
I would simplify this to:
String String::operator+(const String& other) { // Note: Your class is not implemented in a way // That makes this efficient. But with a tiny bit of work. // This can be made to be just as efficient. String result(*this); return result += other; }
In std::string
the operator[]
is not checked. It is assumed you have pre-checked. This is to optimize for speed. The std::string
has a seprate method that has a checked bounds check at()
.
char& String::operator[](std::size_t index) { if (index >= size) { throw std::out_of_range("Index out of range"); } return data[index]; }
Consider the following:
1: You update your code to support either size()
or begin()
and end()
. In this case you can loop over the code:
for (int loop = 0; loop < string.size(); ++loop) { std::cout << string[loop] << "n"; }
Here we know that loop
is always inside the correct bounds and will never throw. So why am I paying the price of checking the loop bounds on every access to operator[]
when I have already guaranteed that accesses will be within bounds.
Lets simplify this to a single line:
std::ostream& operator<<(std::ostream& os, const String& str) { // Note: You are ssuming that there are no null characters in this string. os << str.data; return os; }
I would write like this:
std::ostream& operator<<(std::ostream& os, String const& str) { return os << std::string_view(str.data, str.size); }