Implement String Class

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:

  1. Mastery of Dynamic Memory Management: Demonstrating your understanding of pointers, new, and delete.
  2. Knowledge of the Rule of Five: Implementing constructors, destructors, and assignment operators effectively.
  3. 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:

  1. A private pointer to dynamically allocate memory for the string.
  2. 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?
  1. Provides a safe starting state for an empty String object (nullptr with a length of 0).
  2. 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 double delete 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:

  1. An object is initialized from another object of the same type.

    MyClass A;  // Default constructor called
    MyClass B = A; // Copy constructor called 
  2. An object is passed by value to a function.

    void foo(MyClass a); // Function accepting MyClass by value
    
    foo(a); // Copy constructor invoked
  3. 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:

  1. Primitive Data Members:
    • Directly copied. For example, integers, floats, and booleans are straightforwardly copied.
  2. 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.
  3. 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:

  1. Simple Assignment Between Objects:

    MyClass obj1;
    MyClass obj2;
    obj2 = obj1; // Assignment operator called
    
    • obj2 is assigned the value of obj1. The assignment operator ensures obj2 copies the content of obj1.
  2. Chained Assignments:

    obj1 = obj2 = obj3;
    
    • The assignment operator is called multiple times:
      • obj2 = obj3 is executed first.
      • The result of obj2 = obj3 is assigned to obj1.
  3. 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 to obj1.

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 using delete[].
  • 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;
    }