Lesson 8/41 · Go Language Intro & Dev Environment Beginner m01-l08

Strings and Unicode: rune, byte and the strings Package

Module 1: Go Language Intro & Dev Environment | Lesson: m01-l08 | Level: Beginner After this lesson you will be able to: understand the underlying byte storage of Go strings, master the difference between rune and byte, and use the strings package efficiently for string processing.

Learning Objectives

  • Explain the relationship between string immutability and UTF-8 encoding in Go.
  • Distinguish byte from rune, and understand the decoding that happens in a for range loop.
  • Use strings.Builder to build strings efficiently without unnecessary memory allocations.
  • Master the common lookup, split and replace APIs in the strings package.

Core Concepts

1.1 What string Is Under the Hood and Why It Is Immutable

In Go, a string is an immutable sequence of bytes. Under the hood, a string is effectively a read-only byte slice ([]byte), holding a pointer to the underlying byte array and a length.

Why is it designed this way? 1. Safety: immutability means strings can be shared safely across goroutines without locks. 2. Performance: when a string is used as a map key, hashing can be optimized; and string slicing (e.g. s[1:3]) usually just creates a new header sharing the underlying memory, at almost no cost. 3. Consistency: it fits Go's "explicit over implicit" philosophy and prevents accidental modification.

Note: len(s) returns the byte length, not the character count. For strings containing non-ASCII characters (such as Chinese characters), the byte length is usually larger than the character count.

1.2 rune vs byte, and for range Decoding

Go offers two types for dealing with characters: - byte: an alias of uint8, representing a single byte. - rune: an alias of int32, representing a Unicode code point.

The UTF-8 encoding of one Unicode code point occupies 1–4 bytes; a rune itself is int32, always 4 bytes in memory.

The decoding mechanism of for range When you iterate over a string with for range, the Go runtime automatically performs UTF-8 decoding:

for i, r := range s {
    // i: the byte index of the current rune in the string
    // r: the decoded rune value
}

If the string contains an invalid UTF-8 sequence, r becomes a special replacement character and i advances by 1 byte to skip the erroneous part.

1.3 The strings Package and strings.Builder

The strings package provides a large number of efficient string-processing functions. For cases where strings are concatenated frequently, never use the + operator (which produces a flood of temporary strings); instead use strings.Builder.

The String() method of strings.Builder requires no extra copy: it converts the internal byte slice into a string zero-copy through unsafe (which is why a Builder must not be copied — the runtime detects this and panics). This is the core advantage of strings.Builder over bytes.Buffer.

Example Code

Example 1: Iterating a String by Bytes and by Characters

This example shows how to iterate over a string, distinguishing byte indices, byte values and decoded rune values.

package main

import (
    "fmt"
)

func main() {
    // UTF-8 encoding of "Go 中":
    // 'G'  : 0x47
    // 'o'  : 0x6F
    // ' '  : 0x20
    // '中' : 0xE4 0xB8 0xAD (3 bytes)
    s := "Go 中"

    fmt.Println("Byte iteration:")
    for i := 0; i < len(s); i++ {
        fmt.Printf("Index %d: byte %d (0x%02x)\n", i, s[i], s[i])
    }

    fmt.Println("\nRune iteration:")
    for i, r := range s {
        fmt.Printf("Index %d: rune %c (U+%04X)\n", i, r, r)
    }

    fmt.Printf("\nByte length: %d\n", len(s))
    fmt.Printf("Rune count: %d\n", len([]rune(s)))
}

Expected output:

Byte iteration:
Index 0: byte 71 (0x47)
Index 1: byte 111 (0x6f)
Index 2: byte 32 (0x20)
Index 3: byte 228 (0xe4)
Index 4: byte 184 (0xb8)
Index 5: byte 173 (0xad)

Rune iteration:
Index 0: rune G (U+0047)
Index 1: rune o (U+006F)
Index 2: rune   (U+0020)
Index 3: rune 中 (U+4E2D)

Byte length: 6
Rune count: 4

Example 2: Efficient Concatenation with strings.Builder

This example shows how to use strings.Builder to build a long string, and demonstrates its zero-copy property and common APIs.

package main

import (
    "fmt"
    "strings"
)

func main() {
    var sb strings.Builder

    // Preallocate memory to reduce growths
    sb.Grow(81) // 9*8 + 9 = 81

    for i := 1; i <= 10; i++ {
        sb.WriteString(fmt.Sprintf("Item %d, ", i))
    }

    // String() returns the result zero-copy
    result := sb.String()

    fmt.Printf("Result length: %d\n", len(result))
    fmt.Printf("First 20 chars: %s\n", result[:20])

    // Demonstrate common strings package APIs
    s := "Hello, Go! Hello, World!"

    // Find a substring
    idx := strings.Index(s, "Go")
    fmt.Printf("Index of 'Go': %d\n", idx)

    // Split
    parts := strings.Split(s, ", ")
    fmt.Printf("Split: %v\n", parts)

    // Replace
    replaced := strings.ReplaceAll(s, "Hello", "Hi")
    fmt.Printf("Replaced: %s\n", replaced)
}

Expected output:

Result length: 81
First 20 chars: Item 1, Item 2, Item 3
Index of 'Go': 7
Split: [Hello, Go! Hello, World!]
Replaced: Hi, Go! Hi, World!

Common Mistakes & Pitfalls

  1. Misusing len() to get the character count

    • Wrong assumption: len("中") returns 1.
    • Why: len() returns the byte length; a Chinese character occupies 3 bytes in UTF-8.
    • Correct approach: use len([]rune(s)) to get the character count, or use utf8.RuneCountInString(s).
  2. Repeatedly concatenating strings with + in a loop

    • Wrong code: s := ""; for ... { s += item }
    • Why: each + creates a new string object and allocates memory, giving $O(N^2)$ time complexity.
    • Correct approach: use strings.Builder or bytes.Buffer, for $O(N)$ time complexity.
  3. Operating on non-ASCII strings byte by byte

    • Wrong code: s[i] = 'A' or strings.ToUpper(s[i])
    • Why: string is immutable, so you cannot assign through an index; and processing Chinese characters byte by byte produces garbled text or logic errors.
    • Correct approach: convert to []rune before manipulating, or use the Unicode-aware functions in the strings package (such as strings.ToUpper).

Best Practices

  1. Prefer strings.Builder Whenever you need to build a string, always use strings.Builder. Its String() method is zero-copy, outperforming bytes.Buffer and string concatenation.

  2. Avoid unnecessary type conversions If you only need byte-level processing (e.g. ASCII data), work directly with string and byte. Only convert to []rune when dealing with Unicode text.

  3. Use strings.NewReplacer for multiple replacements If you need to replace several different substrings, use strings.NewReplacer instead of calling Replace repeatedly; it makes a single pass and avoids producing intermediate strings.

  4. Mind the index semantics of for range In for i, r := range s, i is the byte index and r is the decoded rune. If the string contains invalid UTF-8, i may not be consecutive.

Exercises

  1. Concept question Explain why Go's string type is designed to be immutable. What impact does this have on concurrent programming?

  2. Coding exercise Write a function CountRunes(s string) int that counts the number of Unicode characters (i.e. runes) in the string s. You must not use the direct conversion len([]rune(s)); implement it by iterating.

  3. Advanced/debugging exercise The code below tries to convert lowercase letters in a string to uppercase, but has a compile or logic error. Find the problem and fix it. ```go package main

    import ( "fmt" "strings" )

    func main() { s := "hello 世界" for i := 0; i < len(s); i++ { // Try to uppercase the current byte s[i] = strings.ToUpper(string(s[i]))[0] } fmt.Println(s) } ```

Reference Answers

Reference Answers (click to expand / collapse)

Question 1

Answer: the main reasons Go strings are immutable are thread safety and performance optimization. 1. Thread safety: immutable objects can be shared across goroutines without locks, avoiding race conditions. 2. Performance: string slicing (e.g. s[1:3]) only copies the header (pointer and length), sharing the underlying memory at almost no cost. In addition, immutability keeps the semantics stable when strings are used as map keys.

Question 2

package main

import (
    "fmt"
    "unicode/utf8"
)

// CountRunes counts the number of runes in a string
func CountRunes(s string) int {
    count := 0
    for len(s) > 0 {
        _, size := utf8.DecodeRuneInString(s)
        if size == 0 {
            // Handle an invalid UTF-8 sequence by skipping 1 byte
            size = 1
        }
        count++
        s = s[size:]
    }
    return count
}

func main() {
    fmt.Println(CountRunes("Hello 世界")) // prints: 8
}

Question 3

Problem analysis: 1. s[i] = ... is a compile error: string is immutable and cannot be assigned through an index. 2. Logic error: even after converting to []rune, applying strings.ToUpper byte by byte does nothing for non-ASCII characters (such as Chinese), and string(s[i]) turns a byte into a string and then uppercases it — the logic is muddled.

Fixed code:

package main

import (
    "fmt"
)

func main() {
    s := "hello 世界"
    // The correct approach: use strings.ToUpper on the whole string,
    // or convert to []rune and process each rune.
    r := []rune(s)
    for i, char := range r {
        if char >= 'a' && char <= 'z' {
            r[i] = char - 32 // simple uppercasing, ASCII only
        }
    }
    fmt.Println(string(r)) // prints: HELLO 世界
}

Lesson Summary

  • Go's string is an immutable UTF-8 byte sequence, and len() returns the number of bytes, not characters.
  • byte is for single bytes and rune is for Unicode code points; for range handles UTF-8 decoding automatically.
  • Use strings.Builder for string concatenation; its String() method is zero-copy and the most performant.
  • Avoid concatenating strings with + in loops, and avoid modifying non-ASCII strings byte by byte.

Next lesson preview: the Module 1 capstone project — building a command-line word counter, mini-wc.

Feedback

This site is front-end only — no backend, no accounts — so feedback goes to GitHub Issues, Discussions, or email.

Paste it into the issue or email to help track it down — it never includes anything you typed

Also:Browse existing issues · Email feedback (no GitHub account needed):278975598@qq.com