In coding interviews, you might be asked to implement the string class from scratch. This task involves dynamic memory allocation, constructors, destructors, operator overloading, and ensuring exception safety.
Why Create a Custom String Class?
C++ already provides the versatile std::string
class, but writing your own implementation showcases:
- Mastery of Dynamic Memory Management: Demonstrating your understanding of pointers,
new
, anddelete
. - Knowledge of the Rule of Five: Implementing constructors, destructors, and assignment operators effectively.
- Proficiency in Operator Overloading: Making custom classes intuitive and easy to use.
Interviewer Prompt:
“Design and implement a basic String
class with dynamic memory allocation, proper constructors/destructors, and some key functionalities like concatenation and indexing.”
Your Response:
- Confirm the requirements:
- Should it handle dynamic strings (char arrays)?
- Do we need to focus on memory management (dynamic allocation)?
- Should it support operations like concatenation (
+
operator) and indexing ([]
operator)?
- Ask clarifying questions:
- Is the string limited to ASCII characters, or should we handle wide characters?
- Should we prioritize performance or simplicity?
1 Design the Class Skelton:
Start with a basic outline of the String
class, highlighting the need for:
- A private pointer to dynamically allocate memory for the string.
- A length field to store the string's size.
class String {
private:
char* data; // Pointer to hold dynamically allocated memory
size_t length; // Length of the string
public:
String(); // Default constructor
String(const char* str); // Parameterized constructor
~String(); // Destructor
};
2 Implement the Constructors
The constructors are responsible for initializing the object:
Default Constructor:
// Default Constructor
String() : data(nullptr), length(0) {}
Why?
- Provides a safe starting state for an empty
String
object (nullptr
with a length of0
). - Prevents undefined behavior when the object is created but not initialized.
Parameterized Constructor:
// Parameterized Constructor
String(const char* str) {
if (str) {
length = std::strlen(str); // Calculate the string length
data = new char[length + 1]; // Allocate memory (including null terminator)
std::strcpy(data, str); // Copy the input string
} else {
length = 0;
data = nullptr;
}
}
Why?
- Allows us to create a
String
object directly from a C-style string (const char*
). - Dynamically allocates memory to store the input string.
Adds convenience for initialization:
String greeting("Hello, World!");
3 Handle Destructor
The destructor ensures that any dynamically allocated memory is freed when the object is destroyed, preventing memory leaks.
~String() {
delete[] data; // Free dynamically allocated memory
}
Why?
- Prevents memory leaks by ensuring that memory allocated by the constructor is released when the object is destroyed.
- Demonstrates proper resource management, critical in languages like C++ where there is no garbage collector.
4 Copy Constructor
String(const String& other) {
length = other.length;
if (other.data) {
data = new char[length + 1]; // Allocate new memory
std::strcpy(data, other.data); // Copy the string
} else {
data = nullptr;
}
}
Why?
Enables deep copying to avoid issues where multiple objects share the same memory (shallow copying).
For example:String str1("Hello"); String str2 = str1; // Without a copy constructor, both objects would point to the same memory.
- A deep copy ensures
str2
gets its own copy of the data, avoiding accidental modifications or doubledelete
errors.
5 Copy Assignment Operator
String& operator=(const String& other) {
if (this == &other) return *this; // Handle self-assignment
delete[] data; // Free existing memory
length = other.length;
if (other.data) {
data = new char[length + 1]; // Allocate new memory
std::strcpy(data, other.data); // Copy the string
} else {
data = nullptr;
}
return *this;
}
Why?
Handles reassignments like:
String str1("Hello"); String str2; str2 = str1; // Ensure str2 gets a deep copy of str1.
- The self-assignment check avoids unnecessary work and ensures stability if
str1 = str1
.
6 Move Constructor:
String(String&& other) noexcept : data(other.data), length(other.length) {
other.data = nullptr; // Leave the moved object in a safe state
other.length = 0;
}
Why?
- Transfers ownership of resources from a temporary object to a new one, avoiding expensive deep copying.
Example:
String str = String("Temporary");
- Instead of allocating new memory and copying the data, the move constructor takes ownership of the temporary object's memory.
7 Move Assignment Operator
String& operator=(String&& other) noexcept {
if (this == &other) return *this; // Handle self-assignment
delete[] data; // Free existing memory
data = other.data; // Transfer ownership
length = other.length;
other.data = nullptr;
other.length = 0;
return *this;
}
Why?
- Avoids expensive copying during reassignment.
Example:
String str1("Hello"); String str2; str2 = std::move(str1); // str1 transfers its resources to str2.
8 Concatenation (+
Operator):
String operator+(const String& other) const {
size_t newLength = length + other.length;
char* newData = new char[newLength + 1];
if (data) std::strcpy(newData, data);
if (other.data) std::strcat(newData, other.data);
return String(newData);
}
Why?
Provides a natural way to combine strings:
String str1("Hello"); String str2(" World"); String str3 = str1 + str2; // Produces "Hello World"
- Highlights efficient memory allocation for combining two strings.
9 Indexing ([]
Operator):
char& operator[](size_t index) {
if (index >= length) throw std::out_of_range("Index out of range");
return data[index];
}
Why?
Provides intuitive access to individual characters:
String str("Hello"); char c = str[1]; // 'e' str[0] = 'h'; // Change to "hello"
Step by Step Process
First we will make make the basic layout of the class String
. It will have two data member variable. One is the pointer to char and other is the length of the string.
1 Bare String Class
class String {
public:
private:
char* data;
int length;
};
2 Add Default Constructor
The default constructor would does nothing but initialize the data pointer to null and set the length to 0.
Because if we don't initialize them they might contain the garbage values.
class String {
public:
// Default Constructor - To initialize the data members
String() {
data = nullptr;
length = 0;
}
private:
char* data;
int length;
};
Now, we can create the object of our class, while doing so - the default constructor will be called which would initialize the data members.
int main() {
String str1();
}
3 Add Parameterized Constructor
C++ does not provide a default parameterized constructor. If you do not explicitly define a constructor in your class, C++ provides a default constructor, but it is an empty, non-parameterized constructor.
If we do not define the parameterized constructor then doing String str("adam");
It will cause the compilation error.
class String {
public:
// Default Constructor - To initialize the data members
String() {
data = nullptr;
length = 0;
}
// Parameterized Constructor
String(const char* str) {
cout << "Parameterized Constructor" << endl;
int strlength = strLen(str); // Calculate the length of the input string
cout << "Length = " << strlength << endl;
data = new char[strlength + 1]; // Allocate memory for the string (+1 for null terminator)
strCpy(data, str); // Copy the input string into the data member
length = strlength; // Set the length member
}
private:
char* data;
int length;
};
We would need two utilities function here:
// Function to calculate the length of a C-style string
int strLen(const char* str) {
const char* strPtr = str; // Pointer to traverse the string
int length = 0; // Initialize length counter
while(*strPtr != '\0') { // Loop until the null terminator is encountered
length++;
strPtr++; // Move pointer to the next character
}
return length; // Return the calculated length
}
// Function to copy one C-style string to another
void strCpy(char* dest, const char* src) {
char* destPtr = dest; // Pointer to the destination string
while(*src != '\0') { // Loop until the null terminator is encountered
*destPtr = *src; // Copy the character from source to destination
destPtr++; // Move destination pointer
src++; // Move source pointer
}
*destPtr = '\0'; // Add null terminator to the destination string
}
We can use the them from C
library, but we defined them to learn their working.
Now we can create object of string class by passing our own string which would call the parameterized constructor:
int main() {
String str2("MaK");
}
4 Add Destructor
Since we allocated the resources in the constructor which is memory allocation using new
keyword, so its our responsibility to free those resources when we are done or when the object goes out of the scope, otherwise memory leak will occur. Destructor is a place where we do so.
~String() {
cout << "Destruction began" << endl;
delete[] data;
}
This destructor deallocated the memory allocated in the constructor.
This way we are avoiding memory leak.
int main() {
{
String str2("MaK");
}
// str2 goes out of the scope here and its destructor gets called.
// which deallocate the allocated memory, thus
// avoiding memory leak.
}
5 Add Copy Constructor
C++
compiler by default provides a copy constructor, which takes the other object of the same class to create a new object as a copy of an existing object.
It is called when:
An object is initialized from another object of the same type.
MyClass A; // Default constructor called MyClass B = A; // Copy constructor called
An object is passed by value to a function.
void foo(MyClass a); // Function accepting MyClass by value foo(a); // Copy constructor invoked
An object is returned by value from a function.
MyClass foo () { MyClass temp; .... return temp; // Copy constructor called } MyClass obj = createObject(); // Copy constructor called
The copy constructor takes a reference to an object of the same class as a parameter:
ClassName(const ClassName& obj);
const
ensures the source object is not modified.&
prevents unnecessary copying (avoids recursion and infinite loops).
If we don't define our custom copy constructor and call the below code in the main function:
#include <iostream>
using namespace std;
int strLen(const char* str) {
const char* strPtr = str;
int length = 0;
while(*strPtr != '\0') {
length++;
strPtr++;
}
return length;
}
void strCpy(char* dest, const char* src) {
char* destPtr = dest;
while(*src != '\0') {
*destPtr = *src;
destPtr++;
src++;
}
}
class String{
public:
String() {
data = nullptr;
length = 0;
}
String(const char* str) {
cout << "Parameterized Constructor" << endl;
int strlength = strLen(str);
cout << "Length = " << strlength << endl;
data = new char[strlength + 1];
strCpy(data, str);
length = strlength;
}
~String() {
cout << "Destruction began" << endl;
delete[] data;
}
void print() {
const char* temp = data;
while(*temp != '\0') {
cout << *temp;
temp++;
}
cout << endl;
}
void rename(const char* str) {
strCpy(this->data, str);
}
int size(){
return length;
}
private:
char* data;
int length;
};
int main() {
String str2("Adam");
String str3(str2); // Calls the default copy constructor, doing shallow copy.
// If we do change in data part of any string, it will reflect in other too.
str2.rename("Ross");
str3.print();
}
Output:
Ross
As you can see we changed the data part of the str2
and printed the value of str3
. The changed value of str2
is reflected in str3
.
So we have to define our own custom copy constructor to do deep copy.
// Copy constructor
String(const String& original) {
cout << "Copy Constructor Called" << endl;
if (original.data) {
length = original.length;
data = new char[length + 1]; // Allocate memory for copy
strCpy(data, original.data); // Safely copy string
} else {
length = 0;
data = nullptr; // Handle null case
}
}
6 Copy Assignment Operator
The copy assignment operator in C++ is used to copy the contents of one object to another existing object. It is different from the copy constructor, which is used to create a new object as a copy of an existing one.
If we don't define a custom copy assignment operator, C++ provides a default copy assignment operator for the class.
This default copy constructor performs a member-wise (shallow) copy of all non-static members of the class. Specifically:
- Primitive Data Members:
- Directly copied. For example, integers, floats, and booleans are straightforwardly copied.
- Pointers and Dynamically Allocated Memory:
- The pointer values (addresses) are copied, but the memory they point to is not duplicated (shallow copy). This can lead to problems like double deletion and memory corruption.
- Objects as Members:
- The copy assignment operator of the member's type is used (if it exists), or the default one is applied recursively.
Situations where the Assignment Operator is Called:
Simple Assignment Between Objects:
MyClass obj1; MyClass obj2; obj2 = obj1; // Assignment operator called
obj2
is assigned the value ofobj1
. The assignment operator ensuresobj2
copies the content ofobj1
.
Chained Assignments:
obj1 = obj2 = obj3;
- The assignment operator is called multiple times:
obj2 = obj3
is executed first.- The result of
obj2 = obj3
is assigned toobj1
.
- The assignment operator is called multiple times:
Assigning Returned Objects (By Value):
obj1 = createObject(); // Assignment operator called
- Here,
createObject()
returns an object by value, and the assignment operator is invoked to assign its value toobj1
.
- Here,
Key Points:
1 Self-Assignment Check:
if (this == &other) {
return *this;
}
This ensures the object doesn't accidently overwrite its own data when assigned to itself. Without this check, deleting the current object’s data (delete[] data
) would lead to undefined behavior when copying the same data.
So Always implement a self-assignment check.
2 Proper Resource Management:
- First, the existing memory (
data
) is freed usingdelete[]
. - Then, new memory is allocated, and the contents of
other.data
are copied. - Always free existing resources (
delete[]
) before allocating new memory.
3 Chaining Support:
The assignment operator returns a reference to the current object (*this
), allowing assignment chaining like this:
str1 = str2 = str3; // Works correctly
Below is the code for the Copy Assignment Operator:
String& operator=(const String& other) {
if(this == &other) return *this; // Self-assignment check
delete[] data; // Clean up existing memory
data = new char[strLen(other.data) + 1];
strCpy(data, other.data);
cout << "Assignment Operator called" << endl;
return *this;
}