C++ String Class
Hero Image
Language:C++
Tools Used:Visual Studio
Duration:~1 week
Completion:2024

This is a C++ string class I built from scratch, designed to minimize heap allocations and optimize memory usage. It operates at a low level to manage memory efficiently, with features like Reserve , format , Append , a powerful Format method that uses templates and variadic arguments to handle a wide range of input types, and more!

It supports transformations between char , std::string , and numeric types like int , long , double , or float (all via variadic templates), along with other useful methods. One of the highlights is its performance. It's around 20 times faster than Unreal Engine's FString and about 10 times faster than std::string for string interpolation. This is because the string data and all temporary string objects needed for building strings are constructed in place (reusing the existing buffer), which avoids allocations, garbage generation and potential GC spikes altogether while improving speed.

This project gave me the opportunity to brush up on my C++ skills (which I had my first contact when I was 15) and enjoy the advanced memory management that C++ offers, something that higher-level languages like C#, Java, or Python often abstract away. It's a tool I'm proud of, and I used it in some of my other projects.

Technical Details and Benchmark

Here's a quick overview of some of the features it offers (summarized header file):

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
#pragma once

#include <string>
#include "CoreMinimal.h"

#define SSO_THRESHOLD 16 // Strings shorter than this use SSO (Small String Optimization)
  
namespace String_Internal {
  
  class FFastString;
  
  /**
   * @brief Temporary fixed-size string buffer for small string optimization (SSO).
   * @tparam S Size of the buffer (including null-terminator).
   */
  template<size_t S>
  class TStringTemp
  {
  private:
    char Buffer[S] = {}; // Fixed-size character buffer
    unsigned StrLen = 0; // Length of the stored string (excluding null-terminator)
  public:
    /**
     * @brief Constructs a temporary string from a C-string.
     * @param InCStr Input C-string to be stored in the buffer.
     */
    TStringTemp(const char* InCStr)
    {
      StrLen = static_cast<unsigned int>(strlen(InCStr)) + 1;
      if (S < StrLen)
      {
        for (unsigned int i = 0; i < S - 1; ++i)
          Buffer[i] = InCStr[i];
        Buffer[S - 1] = '\0'; // Ensure null-termination
        StrLen = S;
        return;
      }
      for (unsigned int i = 0; i < StrLen; ++i)
        Buffer[i] = InCStr[i];
    }
    /**
     * @return Pointer to the stored C-string.
     */
    const char* GetCStr() const
    {
      return Buffer;
    }
    /**
     * @return Length of the stored string.
     */
    unsigned int Len() const
    {
      return StrLen;
    }
  };
  
  
  // Helper trait to check for any TStringTemp<S>&&
  template <typename T>
  struct Is_TStringTemp_RValue : std::false_type {};
  
  template <size_t S>
  struct Is_TStringTemp_RValue<TStringTemp<S>&&> : std::true_type {};
  
  template <typename T>
  struct TAll_Valid : std::disjunction<
  std::is_same<std::decay_t<T>, FFastString>,   // Handles FFastString, FFastString&, etc.
  std::is_same<std::decay_t<T>, const char*>,   // Handles const char[N], const char(&)[N]
  std::is_same<std::decay_t<T>, char*>,         // Handles char[N], char(&)[N]
  std::is_same<std::decay_t<T>, FString>,       // Handles FString, FString&, etc.
  std::is_floating_point<std::decay_t<T>>,      // Handles float, double, etc.
  std::is_integral<std::decay_t<T>>,            // Handles int, int&, etc.
  Is_TStringTemp_RValue<T>					    // Handles TStringTemp<S>&& for any S
  > {};
  
  template <typename... Args>
  struct TAll_Valid_Format_Args : std::conjunction<TAll_Valid<Args>...> {};
}

/**
 * @brief Fast string class with small string optimization (SSO) and dynamic allocation.
 */
class FFastString
{
  
private:
  char SsoBuffer[SSO_THRESHOLD]; // Small string buffer (used for short strings)
  char* Buffer = nullptr;        // Pointer to dynamically allocated string buffer
  
  unsigned int StrLen = SSO_THRESHOLD; // Length of the stored string
  unsigned int BufferSize = 0;         // Allocated buffer size
  
  /**
   * @brief Finds the next power of 2 greater than or equal to N.
   * @param N Input value.
   * @return Closest power of 2 greater than or equal to N.
   * Note that we could use other methods for growing the buffer.
   * For instance, `std::string` increases the buffer in steps of 16 B.
   */
  static unsigned int NextPowerOf2(unsigned int N);
  
public:
  /** Default constructor - Initializes an empty string. */
  FFastString();
  
  /** Constructor - Initializes from a C-string. */
  FFastString(const char* InName);
  
  /** Copy constructor. */
  FFastString(const FFastString& InOther);
  
  // ...other constructors (such as move constructor, other string types, etc.)
  
  /** Destructor - Frees allocated memory if necessary. */
  ~FFastString();
  
  // --- Memory Management ---
  
  /**
   * @brief Reserves at least `InStorage` bytes for the string.
   * @param InStorage Minimum size to allocate.
   */
  void Reserve(unsigned int InStorage);
  
  /**
   * @brief Reserves exactly `InStorage` bytes for the string.
   * @param InStorage Exact size to allocate.
   */
  void ReserveExact(unsigned int InStorage);
  
  /** @return The total allocated capacity (including null-terminator). */
  size_t Capacity() const noexcept;
  
  /** @return The length of the string (excluding null-terminator). */
  size_t Len() const noexcept;
  
  
  // --- Operator Overloading ---
  
  void operator=(const char* InCString);
  void operator=(const FFastString& InString);
  
  // ...other operator overloading (+, +=, [], etc.)
  
  
  // --- Append Operations ---
  
  /**
   * @brief Appends an integer to the string.
   * @tparam T Must be an integral type (int, long, long long, int8,..., uint32, etc.).
   * @param InElem Integer to append.
   * @return Reference to `*this` for chaining.
   */
  template <typename T>
  inline typename std::enable_if<std::is_integral<T>::value, FFastString&>::type
  Append(const T InElem);
  
  /**
   * @brief Appends a floating-point number to the string.
   * @tparam T Must be a floating-point type (float, double, long double, etc.).
   * @param InElem Floating-point number to append.
   * @return Reference to `*this` for chaining.
   */
  template <typename T>
  inline typename std::enable_if<std::is_floating_point<T>::value, FFastString&>::type
  Append(const T InElem);
  
  /** Appends a C-string to the current string. */
  FFastString& Append(const char* InString);
  
  // ...other Append overloading
  
  // --- Assignment Operators ---
  
  /** Assigns an integer value to the string. */
  template <typename T>
  inline typename std::enable_if<std::is_integral<T>::value, FFastString&>::type
  Assign(const T InNumber);
  
  /** Assigns a floating-point value to the string. */
  template <typename T>
  inline typename std::enable_if<std::is_floating_point<T>::value, FFastString&>::type
  Assign(const T InNumber);
  
  /** Assigns a floating-point value to the string with specific precision (number of decimals). */
  template <typename T, typename T2>
  inline typename std::enable_if<std::is_floating_point<T>::value, FFastString&>::type
  Assign(const T InNumber, const T2 InFloatPrecision);
  
  /** Assigns an FString value to the string. */
  FFastString& Assign(const FFastString& InString);
  
  /** Assigns an std::string value to the string. */
  FFastString& Assign(const std::string& InString);
  
  // ...other Assign overloading
  
  
  // ...more methods unrelated to Format, Append and Assign (from the Benchmark)
  
  
  // ...a series of utilities needed for numeric operations
  template <typename T>
  inline unsigned int CalculateIntLength(const T InInteger);
  template <typename T, typename T2>
  inline void CopyIntegralToString(T InBuffer, T2 InNumber);
  template <typename T>
  inline unsigned int CalculateFloatLength(const T InInteger, unsigned int InPrecision);
  template <typename T>
  inline unsigned int CalculateFloatLength(const T InNumber);
  template <typename T, typename T2>
  inline void CopyFloatToString(T InBuffer, const T2 InNumber, short InLength);
  
  
  // --- Format Operation ---
  
  // ...a series of format utilities needed for the Format method
  template <size_t S, typename T>
  inline typename std::enable_if<
  !std::is_same<T, FFastString&>::value &&
  !std::is_same<T, String_Internal::TStringTemp<S>&&>::value &&
  !std::is_integral<T>::value &&
  !std::is_floating_point<T>::value>::type
  CalculateArgsLength(const int Index, const T&& InArg) = delete;
  template <typename T>
  inline typename std::enable_if<std::is_integral<T>::value>::type
  CalculateArgsLength(const int Index, const T&& InArg);
  template <typename T>
  inline typename std::enable_if<std::is_floating_point<T>::value>::type
  CalculateArgsLength(const int Index, const T&& InArg);
  
  void CalculateArgsLength(const int Index, const FFastString&& InString);
  void CalculateArgsLength(const int Index, const FFastString& InString);
  void CalculateArgsLength(const int Index, const FFastString* InString);
  void CalculateArgsLength(const int Index, const TUniquePtr<FFastString>&& InString);
  template <size_t S>
  void CalculateArgsLength(const int Index, const String_Internal::TStringTemp<S>&& InString);
  void CalculateArgsLength(const int Index, const char* InCStr);
  
  
  template <size_t S, typename T>
  inline typename std::enable_if<
  !std::is_same<T, FFastString&>::value &&
  !std::is_same<T, String_Internal::TStringTemp<S>&&>::value &&
  !std::is_integral<T>::value &&
  !std::is_floating_point<T>::value>::type
  ProcessArgsIndex(const int Index, const T&& InArg) = delete;
  template <typename T>
  inline std::enable_if_t<std::is_integral_v<T>>
  ProcessArgsIndex(const int Index, const T&& InArg);
  template <typename T>
  inline std::enable_if_t<std::is_floating_point_v<T>>
  ProcessArgsIndex(const int Index, const T&& InArg);
  void ProcessArgsIndex(const int Index, const char* InString);
  void ProcessArgsIndex(const int Index, FFastString&& InString);
  template <size_t S>
  void ProcessArgsIndex(const int Index, String_Internal::TStringTemp<S>&& InString);
  void ProcessArgsIndex(const int Index, TUniquePtr<FFastString>&& InString);
  
  
  /**
   * @brief Formats the string using given arguments.
   * @tparam Args Variadic template arguments.
   * @param InBuffer Format string.
   * @param InArgs Arguments to format.
   * @return Reference to `*this` for chaining.
   */
  template <typename... Args>
  std::enable_if_t<String_Internal::TAll_Valid<Args...>::value, FFastString&>
  Format(const char* InBuffer, Args&&... InArgs);
  
  /** @return Pointer to the stored C-string. */
  const char* GetString() const;
  
  /** @return Pointer to the stored string as a TCHAR. */
  const TCHAR* GetStringCTCHAR() const;

  // ...
};
Copied to clipboard
c++

The following shows how I handled the Format method, perhaps not the most efficient and/or robust way, but good and fast enough for my use cases:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
template <typename... Args>
std::enable_if_t<String_Internal::TAll_Valid<Args...>::value, FFastString&>
FFastString::Format2(const char* InBuffer, Args&&... InArgs)
{

  const char* PtrBase = InBuffer; // Keep track of the original buffer start
  const char* Ptr = InBuffer;     // Pointer used in the second pass
  StrLen = 0;
  int ArgIndex = 0;

  // --- First Pass: Calculate Required String Length ---

  while (*InBuffer)
  {
    if (*InBuffer == '{')
    {
      ++InBuffer;
      ArgIndex = 0;

      // Handle malformed brackets: If '{' isn't followed by a number, treat it as a normal char
      if (!(*InBuffer >= '0' && *InBuffer <= '9'))
      {
        ++StrLen;
      }
      // Extract the argument index (parse digits inside `{}`)
      while (*InBuffer >= '0' && *InBuffer <= '9')
      {
        ArgIndex = ArgIndex * 10  + (*InBuffer - '0');
        ++InBuffer;
      }
      // If properly formatted (i.e., `{n}` where n is a valid index), compute argument length
      if (*InBuffer == '}' && ArgIndex < sizeof...(InArgs))
      {
      	// call the arg by index (knowing the index is in range)
      	(CalculateArgsLength(ArgIndex--, std::forward<Args>(InArgs)), ...);
      }
      else
      {
      	++StrLen; // If invalid, treat `{` as a normal character
      }
    }
    else
    {
	  ++StrLen; // Regular character
    }
    ++InBuffer;
  }
  
  // Add length of null terminator
  StrLen++;
  
  // --- Allocate Memory if Needed ---
  
  if (StrLen > BufferSize)
  {
    // If buffer is dynamically allocated, free it before reallocating
    if (Buffer != SsoBuffer)
    	delete[] Buffer;
    
    // Allocate a new buffer of the next power of 2
    BufferSize = NextPowerOf2(StrLen);
    Buffer = new char[BufferSize];
  }
  
  char* BufferPtr = Buffer; // Pointer to write formatted string
  
  ArgIndex = 0; // Reset argument index
  StrLen = 0;   // Reset string length
  
  
  // --- Second Pass: Build the Formatted String ---
  
  while (*Ptr)
  {
    if (*Ptr == '{')
    {
      ++Ptr;
      ArgIndex = 0;
    
      // Handle malformed '{' without a number (treat as normal character)
      if (!(*Ptr >= '0' && *Ptr <= '9'))
      {
        BufferPtr = Buffer + StrLen;
        *BufferPtr = *--Ptr;
        ++StrLen;
        ++Ptr;
      }
    
      // Parse the argument index
      while (*Ptr >= '0' && *Ptr <= '9')
      {
        ArgIndex = ArgIndex * 10 + (*Ptr - '0');
        ++Ptr;
      }
    
      // If properly formatted (e.g., "{0}"), replace with the argument
      if (*Ptr == '}' && ArgIndex < sizeof...(InArgs))
      {
      	// Start index at 1
      	(ProcessArgsIndex(ArgIndex--, std::forward<Args>(InArgs)), ...);
      	BufferPtr = Buffer + (StrLen - 1);
      }
      else
      {
      	// Copy invalid `{...}` sequence as is
      	BufferPtr = Buffer + StrLen; 
      	*BufferPtr = *Ptr;
      	++StrLen;
      }
    }
    else
    {
      // Copy regular characters
      BufferPtr = Buffer + StrLen;
      *BufferPtr = *Ptr;
      ++StrLen;
    }
    ++Ptr;
  }
  
  // Null terminate the resulting string
  *(++BufferPtr) = '\0';
  ++StrLen;
  
  return *this;
}
Copied to clipboard
c++

I've tested the class against many string implementations, including .NET's System.String and the Standard Library's std::string with libstdc++ versions 11, 17, and 20, Unity's and UE's strings, etc. The result is similar in all cases. Even when employing Small String Optimization (SSO), every string solution allocates temporary objects on the heap when interpolating/concatenating string objects, regardless of the string size. I also tested in build/production configurations, with compiler optimizations enabled, but these temporary allocations exist, leading to performance overhead compared to the custom string class.

For the benchmark, I'll show its performance relative Unreal Engine's FString . While FString is more lightweight than FText (because it doesn't have the overhead of localization and is meant for fast string operations) and is complete and full of features, it follows common practices that I've seen in many standard string libraries: they produce unnecessary memory allocations and performance overhead when manipulating and concatenating strings.

The custom string class was built to address these inefficiencies, focusing on minimizing allocations while maintaining a flexible and efficient API. The idea is to have a buffer that can grow on demand, but the central principle is that the buffer is always reused, thereby avoiding heap allocations (mutable string). And again, another key aspect is that temporary string objects (such as the ones resulting from methods like FString::FromInt() ) are constructed in place, resulting in no allocations.

The benchmark showcases the differences in speed and memory usage between FString (UE's) and FFastString (the custom string class), highlighting how reduced allocations lead to better performance and lower heap fragmentation.

Unreal's FString::Printf and similar methods create a temporary object each time they're called, whereas FFastString uses a variadic template-based Format function that construct in place, formatting without any allocations. Similarly, Append operations in FString create transient objects when converting numbers to strings, while FFastString directly appends numeric values (and other types) to the buffer. This seemingly small difference adds up over thousands of operations.

For the benchmark, both strings were preallocated with Reserve(64) to ensure no extra allocations inside the loop. The first test focused on formatted string creation, generating a message containing a player name, ID, and score. The second test measured repeated appends of numbers, something commonly seen in logs and UI updates:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
constexpr int32 NumStrings = 10000;

void ACPP_actor1::BenchmarkWarmup()
{
  // Warm-up phase
  FString WarmUp;
  for(int32 i = 0; i < 1000; i++)
  {
    WarmUp += TEXT("warm");
  }
}

void ACPP_actor1::RunStringBenchmarks()
{
  BenchmarkWarmup();

  FString UEString;
  FFastString FastString;

  // Pre-allocates a big enough buffer to ensure
  // no extra allocations are needed
  UEString.Reserve(64);
  FastString.Reserve(64);

  // Test 1: Formatted String Creation
  {
    {
      TRACE_CPUPROFILER_EVENT_SCOPE(TEXT("CPU_Tag_Format_FString"));
      LLM_SCOPE_BYTAG(MemTagFormatFString);

      for(int32 i = 0; i < NumStrings; i++)
      {
        UEString = FString::Printf(TEXT("Player %s (ID: %d) scored %.2f points"), TEXT("RandomName"), i, static_cast<float>(i) * 1.57f);
      }
    }

    {
      TRACE_CPUPROFILER_EVENT_SCOPE(TEXT("CPU_Tag_Format_FFastString"));
      LLM_SCOPE_BYTAG(MemTagFormatFFastString);

      for(int32 i = 0; i < NumStrings; i++)
      {
        FastString.Format("Player {0} (ID: {1}) scored {2} points", "RandomName", i, static_cast<float>(i) * 1.57f);
      }
    }

  }

  UE_LOG(LogTemp, Warning, TEXT("FString Format Result ------> %s"), *UEString);
  UE_LOG(LogTemp, Warning, TEXT("FFastString Format Result --> %hs"), FastString.GetString());

  // Test 2: String Building
  {
    {
      TRACE_CPUPROFILER_EVENT_SCOPE(TEXT("CPU_Tag_StringBuild_FString"));
      LLM_SCOPE_BYTAG(MemTagAppendFString);

      for(int32 i = 0; i < NumStrings; i++)
      {
        UEString.Reset(64); // we reset the string without allocating, for fairness comparison
        UEString.Append(TEXT("This is a string: "));
	    UEString.Append(FString::FromInt(i)); // creates temporal strings (allocation)
	    UEString.Append(FString::SanitizeFloat(FMath::FRand()));  // creates temporal strings (allocation)
      }
    }

    {
      TRACE_CPUPROFILER_EVENT_SCOPE(TEXT("CPU_Tag_StringBuild_FFastString"));
      LLM_SCOPE_BYTAG(MemTagAppendFFastString);

      FFastString Result;
      for(int32 i = 0; i < NumStrings; i++)
      {
        // Assign resets the buffer and does not ever allocate for as long the buffer is
        // larger than the assignment
        FastString.Assign("This is a string: "); // Assign = "Empty + Append/=operator"
        FastString.Append(i);  // does not create temporal strings
        FastString.Append(FMath::FRand());  // does not create temporal strings
      }
    }
  }

  UE_LOG(LogTemp, Warning, TEXT("FString Append Result ------> %s"), *UEString);
  UE_LOG(LogTemp, Warning, TEXT("FFastString Append Result --> %hs"), FastString.GetString());
}    
Copied to clipboard
c++
UE logs

PS: By default, floating-point numbers are displayed with sufficient precision to represent their value, up to a maximum of 20 decimal digits (hard cap). The way it works is by either reaching the cap, or stopping after it encounters two zeros in a row. For instance, 2.34001 will output: '2.34'. You can also specify a desired precision to override this rule and display a specific number of decimal places.

The results are:

  • FString Format: 11.8 ms
  • FFastString Format: 2.5 ms (~5x faster)
  • FString Append: 18.4 ms
  • FFastString Append: 868.9 µs (~20x faster)

Here's the Timing comparison, using Unreal Insights:

Benchmark Timing overview

The Format method, around 2-5x times faster, depending on the iterations (the more, the larger the difference, 1.5-2x with a single call):

Benchmark Timing for Format

The Append method, around 20x times faster due to the lack of allocations (no temporary string objects allocated):

Benchmark Timing for Append

On the memory side, using UE's Memory Insights, FString required 10,001 allocations (1 MiB) for formatting and 20,000 allocations (271.5 KiB) for appending (2 temporary string objects from numeric types per iteration), whereas FFastString completed both tests with zero allocations.

Benchmark Timing for Append

As you can see, the 'MemTag's for the FFastString ( Format and Append ) don't show up since no allocation was registered. In fact, if we include the initial allocation due to the Reserve(64) call, we see that FFastString shows a single allocation in the whole benchmark:

Benchmark Timing for Append

A speed difference of 20x means that you could do another 190,000 string operations at the same cost as Unreal's 10,000 operations. But this is not just about raw speed: allocating unnecessarily can cause bigger issues down the line. Small, frequent allocations can lead to heap fragmentation, where memory becomes increasingly inefficient over time. They also introduce cache misses due to non-contiguous memory usage, and worst of all, they contribute to garbage build-up, causing unpredictable GC spikes that can introduce frame hitches in real-time applications.

The custom string class was built to solve these problems in a way that remains simple in concept. Now, of course, it doesn't provide every utility of FString, it just covers for now the core functionality efficiently: Format , ToString , Assign , Append , TempString , operator overloading, and full support for all numeric types ( long long , long , int , float , double , etc.). It also provides conversion with const char* , std::string and FString , and additional support could easily be added for other string types.

For personal projects, this lightweight design was good enough. Expanding it further is always an option, but the main takeaway here is that even something as simple as optimizing string operations can bring improvements in performance and memory efficiency. A bit of mindful memory management goes a long way.