10 minutes read

In Go, strings are immutable. This means that once you create a string, you can't change it.

If you want to modify the content of an existing string, you should create a new string. It is a working solution, but this approach becomes inefficient when you need to modify many strings. Indeed, each operation creates a new string, making our program consume more memory, which is not ideal for performance.

To solve this problem, you can implement the Builder struct type from the strings package: it allows you to easily build, or concatenate, strings.

Creating a string with strings.Builder

Suppose you wanted to concatenate or join a series of strings together without importing any additional packages apart from fmt. The code required to achieve this would look something like this:

package main

import "fmt"

func concat(strs ...string) string {
    var result string
    for _, str := range strs {
        result += str
    }
    return result
}

func main() {
    fmt.Println(concat("Hello", " World", "!")) // Hello World!
}

Let's examine the concat function; every time we call result += str, a new string gets allocated in memory. As previously mentioned, this happens because strings in Go are immutable, so every time you change, add, or remove contents from a string, a new string gets created.

To make the program more memory efficient, it's better to avoid creating new strings whenever you need to change or modify one. You can achieve this by using the strings.Builder struct type, along with its WriteString() method:

package main

import (
    "fmt"
    "strings"
)

func concat(strs ...string) string {
    var b strings.Builder
    for _, s := range strs {
        b.WriteString(s)
    }
    return b.String()
}

func main() {
    fmt.Println(concat("Hello", " World", "!")) // Hello World!
}

The updated concat function implements the b variable of the strings.Builder type. After that, we call the WriteString() method within the for...range loop to start appending the contents of s to the buffer of b. This way, you can concatenate the strings passed to the builder without creating a new string every time. Finally, the call to the String() method returns the previously accumulated string.

Even though a builder doesn't create a new string every time you change a string, it still allocates new memory for the buffer of the builder to append data to it. In general, using a builder is optimal when you need to concatenate 3 or more strings. We'll take a look at how a builder manages its buffer data further in this topic.

Additional data writing helpers

Apart from the WriteString() method, the strings.Builder type supports three additional methods to write data to the builder: Write(), WriteRune() and WriteByte().

The Write() method allows us to append a slice of bytes to the buffer of the builder:

...

func main() {
    var b strings.Builder
    b.Write([]byte("Hello JetBrains Academy!"))

    fmt.Println(b.String()) // Hello JetBrains Academy!
}

The WriteRune() and WriteByte() methods are very similar; you can use them to append single characters to the buffer of the builder:

...

func concatRunes(runes ...rune) string {
    var b strings.Builder
    for _, r := range runes {
        b.WriteRune(r)
    }
    return b.String()
}

func main() {
    fmt.Println(concatRunes('e', 'm', 'o', 'j', 'i', '😂', '👌', '💯')) // emoji😂👌💯
}

If you need to append the UTF-8 encoding of an arbitrary rune such as an emoji, e.g., 😂👌💯, you need to use the WriteRune() method. However, if you're working with standard ASCII characters you can use the WriteByte() method.

If you tried to pass an emoji such as "😂" to the WriteByte() method, you would get the constant 128514 overflows byte error since it's not a standard ASCII character.

How does strings.Builder organize its data?

So far, you've seen the most basic strings.Builder use cases, but you might be wondering how the strings.Builder type organizes its data internally. Let's have a look.

A very simple explanation is that the strings.Builder type uses an internal buffer slice to store pieces of data. Every time you call any of the Write helpers to write content, the data is appended to the buffer slice internally:

WriteRune() method example

The above diagram showcases the previous WriteRune() method example. Initially, the buffer slice within the b variable of the strings.Builder type starts with a capacity of 8 bytes. After appending all the standard ASCII characters which write "emoji", the buffer still has 3 free bytes. However, when our program reads the next rune (an emoji) — "😂", it requires 4 bytes of space.

Since only 3 free bytes are left, Go automatically allocates a new buffer slice with a bigger capacity, in this case, 20 bytes. Then it copies the contents of the old slice to the new one, after which it appends the emoji rune "😂", followed by the remaining emoji runes — "👌💯".

Take notice that Go allocates memory for the new buffer slice dynamically. This means that Go doesn't allocate the exact memory capacity required to fill the new buffer slice with the remaining emojis. Go will always allocate more memory than the exact capacity required to avoid a buffer overflow! Therefore, after appending the last two emojis, the buffer still ends up with 3 free bytes.

Improving strings.Builder performance

Now that you know how strings.Builder organizes its data internally, let's take a look at how you can preallocate the size of the buffer slice. This way, our code won't require incrementing the allocated memory dynamically.

Suppose you already know the size of the final string you intend to build. You can go ahead and use the Grow() method to preallocate the size of the buffer slice:

...

func main() {
    var b strings.Builder
    b.Grow(61) // We will be writing 61 bytes

    b.WriteString("Countdown to liftoff!\n") // 22 bytes written (including '\n')
    for i := 5; i >= 1; i-- {
        // 5 bytes written for each line (including '\n')
        b.WriteString(fmt.Sprintf("%d...\n", i))
    }
    b.WriteString("Liftoff! 🚀\n") // 14 bytes written (including '\n')

    fmt.Print(b.String())
    fmt.Println("Capacity of 'b' =", b.Cap())
    fmt.Println("Length of 'b' =", b.Len())
}

// Output:
// Countdown to liftoff!
// 5...
// 4...
// 3...
// 2...
// 1...
// Liftoff! 🚀
// Capacity of 'b' = 61
// Length of 'b' = 61

Since you already know how many bytes each line will contain, you can simply pass 61 bytes as an argument to the Grow() method. After writing all the strings, you can print the capacity and length of b with the help of the Cap() and Len() methods and confirm that both the capacity and length of the slice buffer within the b variable are equal to 61.

In case you pass an incorrect amount of bytes to the Grow() method, Go will automatically allocate a new buffer slice with a bigger capacity and copy the contents of the old slice to the new one, beating the purpose of using the Grow() method anyway. Hence, to use the Grow() method effectively, you should know the exact size or a close estimate of the final string you intend to build.

Conclusion

In this topic, you've learned how to concatenate strings with the help of the strings.Builder struct type and its data writing helper methods.

The list below recounts the main theoretical points covered in this topic.

  • What the strings.Builder type is and what its most basic use cases are.

  • Four data writing helpers that the strings.Builder type supports: Write(), WriteString(), WriteRune() and WriteByte().

  • How the strings.Builder type uses a buffer slice to manage its data internally.

  • How to use the Grow() method to preallocate memory for the buffer slice.

This has been a challenging topic, but we're not done yet! Let's solve some theory and coding tasks now to make sure you've learned how to implement strings.Builder along with its methods.

35 learners liked this piece of theory. 0 didn't like it. What about you?
Report a typo