Skip to content

Strings

Strings are arrays of characters.

Now, how do you represent a character in memory (aka as a number)? For this purpose, people have invented encodings. The simplest encoding is the 7-bit encoding called ASCII. The symbols and their values are available e.g. here.

Note that any symbol from ASCII can be represented using 7-bits, so you can store at most one symbol in a byte. Sure, the highest bit is then always 0, to make the value of the byte equivalent to that from the ASCII table.

The type that represents an ASCII character is char. char is an 8-bit type, which can be either signed or unsigned depending on the compiler.

🤓 “8-bit” here is an implementation detail. The standard technically only guarantees that a byte has at least 8 bits (the exact number is CHAR_BIT), and the signedness of char is implementation-defined. In practice, on any modern hardware a byte is 8 bits.

#include <iostream>
int main()
{
char character = 65; // 65 is the code for A in the ASCII table
std::cout << character; // prints A
}

You can also use character literals, to make the compiler look up the number in the ASCII table for you.

char character = 'A';
// Equivalent to:
char character = 65;

You can do arithmetic on a character, aka increment it, or add some offset to it. Now look closely at the capital latin letters in the ASCII table. They are consecutive in the table. This means we can print the whole alphabet by using a loop.

char startCharacter = 'A';
char endCharacter = 'Z';
for (char ch = startCharacter; ch <= endCharacter; ch++)
std::cout << ch;

Same applies to the lowercase alphabet. For example, you can calculate the offset between the start of the capital letters and the start of the lowercase letters to be added to transform letters from uppercase to lowercase.

I urge you to try debugging this code to better understand the idea.

char offset = 'a' - 'A';
for (char ch = 'A'; ch <= 'Z'; ch++)
{
char result = ch + offset;
std::cout << result;
}
// or the other way
char offset = 'A' - 'a';
for (char ch = 'a'; ch <= 'z'; ch++)
{
char result = ch + offset;
std::cout << result;
}

Unicode refers to a standardized set of encodings that can be used to encode any character. The most popular one is UTF-8.

All of the regular ASCII characters are represented in UTF-8 without change. The issue is that we do not have any more space in a byte to represent the other characters, besides the one extra bit that is unused by ASCII. The idea is to use that bit to mean that the character is encoded in multiple bytes.

For example, the character ș can be written in two bytes like C8 99 in hex. What UTF-8 does, is it uses the higher bits to indicate the number of bytes that the character is encoded in.

  • The highest bit being 0 means that this is a self-contained single-byte ASCII character.
  • If the highest bit is 1, the byte belongs to a multi-byte sequence: leading (first) bytes have the pattern 11xxxxxx, while continuation bytes have 10xxxxxx.
  • The number of leading 1s in the first byte, up to the first 0, indicates the number of bytes of the character: 110xxxxx is two bytes, 1110xxxx is three, 11110xxx is four.

The idea is that any byte by itself has the context of whether it’s a self-contained ASCII byte (the first bit is 0), the first byte of a sequence (pattern 11), or one of the other bytes in the sequence (pattern 10).

The table below illustrates the pattern:

Number of bytesByte 1Byte 2Byte 3Byte 4
1 (just ASCII)0…N/AN/AN/A
2110…10…N/AN/A
31110…10…10…N/A
411110…10…10…10…

4 bytes is the max for a single Unicode code point. But one visible character can consist of several code points: such composite entities (like most emojis, assembled from several code points joined by a ZWJ separator) are called grapheme clusters. In UTF-8, such a cluster is represented by several 1–4 byte sequences.

The different terminology can cause confusion, so the term “character” is basically meaningless when talking about UTF-8. It’s better to refer to a character (a single thing that appears in text) as a text element, for example, to prevent confusion. Different sources / implementations choose different terminology for this.

A character that fits into 4 bytes is sometimes called a rune.

There are also UTF-16 and UTF-32, which have the minimum number of bytes for a character increased to 2 and 4 respectively, and use similar ideas for encoding it.

A C string refers to a sequence of characters that ends with a null terminator, aka the number 0. Strings are typically stored in an array, or as a pointer, without storing the length of the string (the number of bytes). This representation comes from C, but it’s widely used in C++ as well. This is why such strings are called C strings. The program is assumed to trust that wherever the string ends, it will have a 0 byte, which is how it can find the end of the string.

#include <iostream>
#include <array>
int main()
{
std::array<char, 6> arr{
'H',
'e',
'l',
'l',
'o',
'\0', // same as just 0
};
// `std::cout` treats `char*` in a special way.
// It assumes that it's a C string, and prints it as such.
std::cout << &arr[0]; // Hello
return 0;
}

And if you wrote a 0 byte in the middle of the string, it will only print it until it finds that.

std::array<char, 6> arr{
'H',
'\0',
'l',
'l',
'o',
'\0',
};
std::cout << &arr[0]; // H

You can use a string literal instead of listing each byte:

std::array<char, 6> arr = "Hello";

String literals can also be assigned to a pointer variable. This would place them into static memory, on some platforms the memory will be immutable (writing to it will crash your program). You shouldn’t write to the memory pointed to by such pointers.

const char* str = "Hello";

We very often need to know the length of the string, or want to refer to only a part of the whole string, which is why storing the length along with the pointer is useful.

In this regard, std::string_view works just like a std::span<const char>. It stores the pointer to the string, along with the length. The only difference is that it has some methods that are useful for strings, and output streams (e.g. std::cout) print it as a string.

For reference, the << operator is not overloaded for std::span for streams, meaning you can’t do std::cout << span;, it just won’t compile.

There’s also some difference in constructors, in that std::string_view can’t be implicitly constructed from an std::array. It can still be constructed from a const char* though, which will make it scan the string until it finds the null termination byte to determine the length.

#include <iostream>
#include <array>
#include <string>
#include <string_view>
int main()
{
std::array<char, 6> arr = "Hello";
std::string_view str{&arr[0]};
std::cout << str.size(); // 5
std::cout << str; // Hello
return 0;
}

Just like with std::span, you can pass it the length in the constructor:

std::array<char, 6> arr = "Hello";
std::string_view str{&arr[0], 2};
std::cout << str; // He

std::string is an RAII type that allows you to allocate a string on the heap.

#include <string>
#include <iostream>
int main()
{
// Construct from a literal.
// Note that the string is copied into a heap allocated buffer
// from the static memory where the literal is stored.
// The local variable stores the pointer to the buffer and the length.
std::string string1{"Hello world!!"};
// pointer + length constructor
// note that it copies the string into a new heap-allocated buffer.
std::string string2{&string1[0], 10};
// This does NOT create a new buffer.
// It points to the buffer of string2.
std::string_view view{string2};
std::cout << string1; // Hello world!!
std::cout << string2; // Hello worl
std::cout << view; // Hello worl
return 0;
// Implicitly added:
// string2.~string()
// string1.~string()
}
std::string string1{"Hello world!!"};
string1[0] = 'L';
std::cout << string1; // Lello world!!

It also supports convenient concatenation operators:

std::string string1{"Hello"};
string1 += " world"; // May reallocate the buffer.
std::cout << string1; // Hello world

Also, for short strings, it doesn’t actually allocate memory on the heap. If the string is short enough, it will store it inside the object itself (typically up to 23 bytes doesn’t allocate). This is called small string optimization.

std::string is basically an std::vector<char>, because its buffer’s capacity and length are separate, which allows you to potentially be adding characters at the end without reallocating it on every add.

In C#, for example, strings are immutable, which means that every time you try to append a character to it, it will create a new string, copy the old string into it, and then append the new character at the end. This is really wasteful, but C#‘s strings win in the sense that they are immutable, which has its benefits.

Do not use const std::string& for the type of a parameter.

If const means that you won’t be able to modify it, then why do you require the string to be an actual std::string? There are plenty of other ways to have a string around, it can be stored in any linear block of memory.

The best option is to replace that with std::string_view which is designed to be used for that purpose. It’s way more flexible, because you can create it without allocating dynamic memory from any pointer + length pair.

The character buffer of any std::string is guaranteed to be null terminated by the C++ standard. To get a pointer to the buffer, you can use the c_str() method.