Os pacotes strings e bytes são fundamentais no desenvolvimento com Go, pois fornecem ferramentas poderosas para trabalhar com texto e dados binários. Nesta aula, vamos mergulhar nas principais funções e tipos desses pacotes, entender como eles se relacionam e como usá-los de forma eficiente. Você aprenderá a manipular strings com funções como Contains, Split e Replace, a construir strings dinamicamente com strings.Builder e a trabalhar com buffers de bytes usando bytes.Buffer. Além disso, discutiremos estratégias para otimizar o desempenho do seu código, evitando alocações desnecessárias e entendendo o custo de certas operações.

Ao final desta aula, você terá uma compreensão sólida de como escolher entre essas ferramentas e como aplicá-las em situações reais, como processamento de texto, parsing e serialização. Vamos começar!

Manipulação de strings

O pacote strings oferece uma vasta gama de funções para trabalhar com strings UTF-8. Diferente de outras linguagens, strings em Go são imutáveis e representam sequências de bytes. Isso significa que operações como concatenação criam novas strings, o que pode impactar a performance se não for feito com cuidado. Vamos explorar as funções mais comuns e suas aplicações.

Algumas funções essenciais incluem: Contains, HasPrefix, HasSuffix, Index, Split, Join, Replace, ToLower, ToUpper e Trim. Cada uma delas resolve problemas específicos de busca, extração e transformação de texto. Por exemplo, Split divide uma string em um slice de substrings com base em um separador, enquanto Join faz o inverso.

package main

import (
    "fmt"
    "strings"
)

func main() {
    texto := "Go é uma linguagem incrível!"
    
    // Verifica se contém uma substring
    fmt.Println(strings.Contains(texto, "linguagem")) // true
    
    // Divide a string em palavras
    palavras := strings.Split(texto, " ")
    fmt.Println(palavras) // [Go é uma linguagem incrível!]
    
    // Junta um slice em uma string
    juncao := strings.Join(palavras, "-")
    fmt.Println(juncao) // Go-é-uma-linguagem-incrível!
    
    // Substitui parte da string
    novo := strings.Replace(texto, "incrível", "fantástica", 1)
    fmt.Println(novo) // Go é uma linguagem fantástica!
    
    // Converte para maiúsculas
    fmt.Println(strings.ToUpper(texto)) // GO É UMA LINGUAGEM INCRÍVEL!
}

É importante notar que a maioria dessas funções é pura, ou seja, não modifica a string original, mas retorna uma nova. Isso é consistente com a imutabilidade das strings em Go. Para manipulações mais complexas, como busca com expressões regulares, o pacote regexp é mais adequado, mas para operações simples, strings é suficiente e mais eficiente.

strings.Builder

Quando você precisa construir strings dinamicamente, como em loops, a concatenação com o operador + pode ser ineficiente, pois cada concatenação cria uma nova string, resultando em múltiplas alocações de memória. O tipo strings.Builder foi introduzido no Go 1.10 para resolver esse problema, fornecendo uma forma eficiente de acumular strings.

O strings.Builder implementa a interface io.Writer e mantém um buffer interno que cresce conforme necessário. Ele é ideal para construir strings em cenários como geração de HTML, SQL dinâmico ou qualquer outra concatenação intensiva. Vamos ver um exemplo prático:

package main

import (
    "fmt"
    "strings"
)

func main() {
    var builder strings.Builder
    
    // Escreve várias partes
    builder.WriteString("Olá, ")
    builder.WriteString("mundo!")
    builder.WriteString(" Este é um exemplo.")
    
    // Obtém a string final
    resultado := builder.String()
    fmt.Println(resultado) // Olá, mundo! Este é um exemplo.
    
    // Também pode escrever bytes
    builder.Write([]byte{' ', 'G', 'o'})
    fmt.Println(builder.String()) // Olá, mundo! Este é um exemplo. Go
}

Uma característica importante do strings.Builder é que ele não copia a string quando você chama String(); em vez disso, retorna uma referência ao buffer interno. Isso significa que você não deve modificar o builder depois de chamar String(), pois o comportamento seria indefinido. Para evitar problemas, é recomendado chamar String() apenas uma vez, no final.

Outra vantagem é que o builder pode ser reutilizado, mas para isso é necessário resetá-lo com o método Reset(). Isso é útil em cenários de pooling, onde você quer evitar alocações desnecessárias.

bytes.Buffer

O pacote bytes fornece o tipo Buffer, que é um buffer de bytes dinâmico. Diferente de strings.Builder, que é otimizado para construir strings, bytes.Buffer é mais genérico e útil para trabalhar com dados binários, como leitura e escrita de arquivos, redes ou serialização. Ele também implementa as interfaces io.Reader e io.Writer, tornando-o versátil.

O bytes.Buffer pode ser usado tanto para escrever quanto para ler dados. Ele mantém um slice de bytes que cresce conforme necessário. Vamos ver um exemplo de uso:

package main

import (
    "bytes"
    "fmt"
)

func main() {
    var buf bytes.Buffer
    
    // Escreve dados
    buf.WriteString("Primeira linha\n")
    buf.WriteString("Segunda linha\n")
    buf.Write([]byte{'T', 'e', 'r', 'c', 'e', 'i', 'r', 'a'})
    
    // Lê o conteúdo
    fmt.Print(buf.String())
    
    // Lê byte a byte
    b, _ := buf.ReadByte()
    fmt.Printf("Primeiro byte: %q\n", b) // 'P'
    
    // Esvazia o buffer
    buf.Reset()
    fmt.Printf("Após reset: %q\n", buf.String()) // ""
}

Uma diferença crucial entre bytes.Buffer e strings.Builder é que o primeiro permite leitura e escrita, enquanto o segundo é apenas para escrita. Se você precisa ler dados após escrever, bytes.Buffer é a escolha certa. Além disso, bytes.Buffer pode ser inicializado com um slice de bytes existente, permitindo manipulação direta de dados.

É importante notar que bytes.Buffer não é seguro para concorrência, assim como strings.Builder. Se você precisar de sincronização, deve usar um sync.Mutex ou outras estruturas.

Performance

Ao trabalhar com strings e bytes, a performance é uma consideração crucial, especialmente em sistemas de alta carga. Vamos analisar as diferenças de custo entre as abordagens e como otimizar seu código.

Concatenação de strings com + em loops é uma das operações mais ineficientes, pois cada concatenação aloca uma nova string e copia o conteúdo antigo. Isso resulta em complexidade O(n²) no número de concatenações. Usar strings.Builder ou bytes.Buffer reduz isso para O(n) amortizado, pois o buffer cresce de forma eficiente.

package main

import (
    "fmt"
    "strings"
)

func main() {
    // Ineficiente
    s := ""
    for i := 0; i < 10000; i++ {
        s += "a"
    }
    
    // Eficiente
    var builder strings.Builder
    for i := 0; i < 10000; i++ {
        builder.WriteByte('a')
    }
    s2 := builder.String()
    
    fmt.Println(len(s), len(s2))
}

Outro ponto é a conversão entre []byte e string. Em Go, converter uma string para []byte e vice-versa não faz cópia do conteúdo, mas cria uma nova referência, o que é eficiente. No entanto, se você modificar o slice de bytes, uma cópia será feita. Portanto, é importante entender quando ocorrem cópias para evitar alocações desnecessárias.

Para medir a performance, você pode usar o pacote testing com benchmarks. Vamos ver um exemplo:

package main

import (
    "strings"
    "testing"
)

func BenchmarkConcat(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := ""
        for j := 0; j < 100; j++ {
            s += "a"
        }
    }
}

func BenchmarkBuilder(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var builder strings.Builder
        for j := 0; j < 100; j++ {
            builder.WriteByte('a')
        }
    }
}

Rodando o benchmark, você verá que o Builder é significativamente mais rápido. Além disso, uma boa prática é pré-alocar o buffer com Grow se você souber o tamanho aproximado, evitando realocações durante o crescimento.

Boas práticas e observações finais

Ao escolher entre strings, strings.Builder e bytes.Buffer, considere a natureza dos dados e as operações necessárias. Para manipulação simples de strings, use o pacote strings. Para construir strings dinamicamente, prefira strings.Builder. Para dados binários ou leitura/escrita, use bytes.Buffer.

Lembre-se de que strings são imutáveis, então qualquer operação que pareça modificar uma string na verdade cria uma nova. Isso é importante para evitar bugs sutis e entender o custo de memória.

Outra dica é evitar conversões desnecessárias entre tipos. Se você tem uma string e precisa escrever em um io.Writer, use io.WriteString em vez de converter para []byte.

Referências

Exercícios

  1. Escreva uma função que receba uma string e retorne a mesma string com todas as palavras invertidas (ex.: "olá mundo" vira "mundo olá"). Use o pacote strings.

    ✓ Resposta:
    func InverterPalavras(s string) string {
        palavras := strings.Fields(s)
        for i, j := 0, len(palavras)-1; i < j; i, j = i+1, j-1 {
            palavras[i], palavras[j] = palavras[j], palavras[i]
        }
        return strings.Join(palavras, " ")
    }
    
  2. Dado um slice de strings, construa uma única string onde cada elemento é separado por vírgula e espaço, usando strings.Builder. Teste com um slice de exemplo.

    ✓ Resposta:
    func JoinComBuilder(slice []string) string {
        var builder strings.Builder
        for i, s := range slice {
            if i > 0 {
                builder.WriteString(", ")
            }
            builder.WriteString(s)
        }
        return builder.String()
    }
    
  3. Use bytes.Buffer para escrever uma sequência de números de 1 a 10, separados por ponto e vírgula, e depois leia o conteúdo como string.

    ✓ Resposta:
    func NumerosBuffer() string {
        var buf bytes.Buffer
        for i := 1; i <= 10; i++ {
            if i > 1 {
                buf.WriteString(";")
            }
            fmt.Fprintf(&buf, "%d", i)
        }
        return buf.String()
    }
    
  4. Escreva um benchmark comparando a concatenação com + e usando strings.Builder para juntar 1000 strings pequenas. Execute o benchmark e observe a diferença.

    ✓ Resposta:
    func BenchmarkConcat(b *testing.B) {
        for i := 0; i < b.N; i++ {
            s := ""
            for j := 0; j < 1000; j++ {
                s += "a"
            }
        }
    }
    
    func BenchmarkBuilder(b *testing.B) {
        for i := 0; i < b.N; i++ {
            var builder strings.Builder
            builder.Grow(1000)
            for j := 0; j < 1000; j++ {
                builder.WriteByte('a')
            }
        }
    }
    
  5. Implemente uma função que remova todas as ocorrências de uma substring de uma string usando strings.Replace e depois usando strings.Builder manualmente. Compare os resultados.

    ✓ Resposta:
    func RemoverSubstring(s, sub string) string {
        return strings.Replace(s, sub, "", -1)
    }
    
    func RemoverSubstringBuilder(s, sub string) string {
        var builder strings.Builder
        for {
            idx := strings.Index(s, sub)
            if idx == -1 {
                builder.WriteString(s)
                break
            }
            builder.WriteString(s[:idx])
            s = s[idx+len(sub):]
        }
        return builder.String()
    }