JavaRush /Курсы /Go SELF /bytes.Buffer и strings.Builder

bytes.Buffer и strings.Builder

Go SELF
26 уровень , 1 лекция
Открыта

1. Почему «собирать вывод» лучше, чем «печать из каждой функции»

Когда вы только учитесь программировать, хочется сделать так: «внутри функции посчитал — там же и fmt.Println». Это нормально на старте: мозгу проще, результат видно сразу. Но довольно быстро начинается боль: вы хотите использовать ту же функцию в другом месте, а она внезапно печатает лишнее, мешает форматированию или вообще пишет “куда не надо”. Поэтому мы разделяем две задачи: посчитать/сформировать текст и решить, куда этот текст отправить.

Представьте, что ваша функция — это повар. Если повар каждый раз выбегает в зал и вслух диктует посетителям рецепт — ресторан быстро закрывается. Повар должен готовить, а официант — приносить. В Go роль «официанта» часто играет io.Writer: вы отдаёте функции «куда писать», и она пишет туда, не зная, консоль это, файл или буфер в памяти.

Сравним «плохой» и «хороший» подход на мини-примере:

package main

import "fmt"

func PrintHelloBad() {
	fmt.Println("hello") // печатаем сразу, неуправляемо
}

func main() {
	PrintHelloBad()
}

Теперь вариант, который оставляет контроль вызывающему коду:

package main

import (
	"fmt"
	"io"
	"os"
)

func WriteHello(w io.Writer) {
	fmt.Fprintln(w, "hello") // пишем туда, куда нам дали
}

func main() {
	WriteHello(os.Stdout) // сейчас это консоль
}

Пока разница кажется философией, но уже скоро будет практический бонус: можно «подменить» os.Stdout на буфер и проверить результат.

2. bytes.Buffer: «память как файл, только без файлов»

bytes.Buffer — одна из самых удобных вещей в стандартной библиотеке Go для работы с байтами в памяти. Он умеет накапливать данные: вы пишете в него кусочки, а он хранит всё «склеенным». При этом он же умеет читать эти накопленные байты как поток, то есть bytes.Buffer одновременно похож и на io.Writer, и на io.Reader.

Важный момент, который приятно удивляет новичков: нулевое значение bytes.Buffer готово к использованию. То есть не нужно make, new, конструкторы и магические инициализации. Просто var buf bytes.Buffer — и можно писать.

Мини-пример: накопим текст (через байты) и выведем как строку:

package main

import (
	"bytes"
	"fmt"
)

func main() {
	var buf bytes.Buffer

	buf.WriteString("go")
	buf.WriteByte('!')

	fmt.Println(buf.String()) // go!
}

А теперь важная «потоковая» особенность: если мы читаем из bytes.Buffer, он потребляет данные. Это поведение похоже на очередь: прочитал — ушло.

package main

import (
	"bytes"
	"fmt"
)

func main() {
	var buf bytes.Buffer
	buf.WriteString("abcdef")

	tmp := make([]byte, 2)

	n, _ := buf.Read(tmp)
	fmt.Printf("%q\n", tmp[:n]) // "ab"

	fmt.Println(buf.String()) // cdef
}

Это иногда неожиданно: вы думали «я просто посмотрю», а данные исчезли. На практике это нормально: Read — это именно чтение потока, а не «подсмотреть». Если нужно «подсмотреть», используйте buf.Bytes() или buf.String() без Read.

3. strings.Builder: быстрый конструктор строки

strings.Builder — это «специалист по строкам». Он нужен, когда ваша цель — собрать строку из кусочков без постоянного + и без лишних временных объектов. Строки в Go неизменяемые, поэтому a = a + b в цикле часто приводит к куче промежуточных строк. strings.Builder решает это аккуратно: он хранит внутренний буфер и растит его по мере необходимости.

Как и у bytes.Buffer, у strings.Builder нулевое значение готово к использованию. Можно написать var b strings.Builder и сразу делать b.WriteString(...).

Простейший пример:

package main

import (
	"fmt"
	"strings"
)

func main() {
	var b strings.Builder
	b.WriteString("hello")
	b.WriteString(" ")
	b.WriteString("go")

	fmt.Println(b.String()) // hello go
}

Есть тонкость, которая часто всплывает именно здесь: чтобы использовать strings.Builder как io.Writer, его обычно передают по указателю&b. Не потому что «в Go всё сложно», а потому что методы у Builder устроены так, что для интерфейса io.Writer обычно подходит именно указатель на Builder.

package main

import (
	"fmt"
	"strings"
)

func main() {
	var b strings.Builder

	fmt.Fprintln(&b, "line 1")
	fmt.Fprintln(&b, "line 2")

	fmt.Print(b.String())
	// line 1
	// line 2
}

Ещё один аккуратный нюанс: strings.Builder не любят копировать после того, как вы начали им пользоваться. Для новичка достаточно правила: «создал Builder — используй его в одном месте и передавай как &b, не делай копий присваиванием».

4. Как выбрать между bytes.Buffer и strings.Builder

На практике очень легко застрять в вопросе «а что правильнее?». Хорошая новость: в большинстве учебных задач правильны оба варианта. Важнее понимать, какой результат вы хотите получить и нужен ли вам Read. bytes.Buffer — универсальнее (байты + чтение), strings.Builder — проще и логичнее для сборки строк.

Сведём это в небольшую табличку, чтобы мозгу было за что зацепиться:

Что сравниваем bytes.Buffer strings.Builder
Основная цель накапливать байты накапливать строку
Можно читать как поток (Read) да нет
Получить строку
buf.String()
b.String()
Получить байты
buf.Bytes()
нет (только строка)
Нулевое значение готово да да
Как использовать с fmt.Fprint* обычно &buf обычно &b

Правило «для жизни»: если вы формируете человекочитаемый текст (отчёт, таблицу, сообщение пользователю) — берите strings.Builder. Если вы работаете с данными как с байтами или вам нужно «и писать, и читать» — берите bytes.Buffer.

fmt.Fprint*: печать в любой io.Writer

До этого мы в основном пользовались fmt.Print, fmt.Println, fmt.Printf. Они пишут в стандартный вывод (обычно это консоль). Но для «сборки вывода» нам нужно уметь писать не только в консоль. Здесь и появляются функции семейства fmt.Fprint*: они делают то же самое, но первым параметром принимают io.Writer.

Очень важно почувствовать это на пальцах: мы не меняем форматирование, мы меняем «куда писать».

Пример: печатаем в консоль (как обычно), но через Fprintln:

package main

import (
	"fmt"
	"os"
)

func main() {
	fmt.Fprintln(os.Stdout, "hello") // hello
}

А теперь печатаем не в консоль, а в буфер:

package main

import (
	"bytes"
	"fmt"
)

func main() {
	var buf bytes.Buffer
	fmt.Fprintln(&buf, "hello")

	fmt.Println(buf.String()) // hello
}

На этом месте обычно приходит «ага-момент»: если функция принимает io.Writer, мы можем направить её вывод куда угодно — хоть в память, хоть в файл, хоть «в два места сразу» (но это уже не сегодня).

5. Мини‑приложение: рендер задач в io.Writer

Чтобы примеры не были набором случайных строк, продолжим одну линию: сделаем маленькое «ядро» для списка задач (мини‑todo). Мы не строим здесь полноценное CLI, не лезем в файлы и базы — нам важно научиться формировать вывод так, чтобы его можно было легко перенаправить и проверить.

Начнём с модели. Пусть задача — это ID, Title и флаг Done. Никакой магии:

package main

type Task struct {
	ID    int
	Title string
	Done  bool
}

Теперь сделаем функцию, которая печатает одну задачу в любой io.Writer. Заметьте: функция не знает, куда пишет.

package main

import (
	"fmt"
	"io"
)

func WriteTask(w io.Writer, t Task) error {
	status := "TODO"
	if t.Done {
		status = "DONE"
	}
	_, err := fmt.Fprintf(w, "#%d [%s] %s\n", t.ID, status, t.Title)
	return err
}

Сразу небольшой комментарий к стилю: fmt.Fprintf возвращает (n int, err error). Число байт (n) нам сейчас не нужно, а ошибка нужна — поэтому _, err := ....

Теперь функция для вывода списка задач, используя WriteTask:

package main

import "io"

func WriteTasks(w io.Writer, tasks []Task) error {
	for _, t := range tasks {
		if err := WriteTask(w, t); err != nil {
			return err
		}
	}
	return nil
}

И пример использования в main, чтобы увидеть результат на экране:

package main

import (
	"os"
)

func main() {
	tasks := []Task{
		{ID: 1, Title: "learn io.Writer", Done: true},
		{ID: 2, Title: "stop printing everywhere", Done: false},
	}
	_ = WriteTasks(os.Stdout, tasks)
	// #1 [DONE] learn io.Writer
	// #2 [TODO] stop printing everywhere
}

Да, мы тут игнорируем ошибку через _ = ..., но только чтобы не распух пример. В «настоящем» коде лучше обработать err.

6. Отчёт строкой: strings.Builder и WriteTasks

Сейчас у нас есть отличная функция WriteTasks(w, tasks). Её можно использовать не только для печати на экран, но и для получения строки — например, чтобы вернуть результат из функции или подготовить сообщение пользователю. И вот здесь strings.Builder раскрывается на 100%: он собирает строку, а интерфейс io.Writer позволяет нам переиспользовать код рендера без копипасты.

Сделаем функцию FormatTasks, которая возвращает строку (и ошибку, чтобы оставаться честными):

package main

import "strings"

func FormatTasks(tasks []Task) (string, error) {
	var b strings.Builder

	if err := WriteTasks(&b, tasks); err != nil {
		return "", err
	}
	return b.String(), nil
}

Проверим на мини‑демо:

package main

import "fmt"

func main() {
	tasks := []Task{{ID: 1, Title: "write report", Done: false}}

	s, err := FormatTasks(tasks)
	fmt.Println(err) // <nil>
	fmt.Print(s)     // #1 [TODO] write report
}

Заметьте, как приятно устроена архитектура: форматирование — в одном месте, направление вывода — в другом. Если позже вы захотите печатать этот же отчёт куда-то ещё, вы не переписываете логику, а просто меняете io.Writer.

7. Проверяем вывод: bytes.Buffer вместо stdout

Проверка вывода — классическая проблема для новичков: «как мне убедиться, что функция напечатала правильно, не глядя глазами?». И вот тут буферы — просто подарок. Мы можем направить вывод в bytes.Buffer, а потом сравнить строку с ожидаемой. Даже если вы ещё не готовы к полноценному go test, идея остаётся той же.

Сначала покажу совсем «без тестового фреймворка» — просто проверка в коде:

package main

import (
	"bytes"
	"fmt"
)

func main() {
	var buf bytes.Buffer
	_ = WriteTask(&buf, Task{ID: 7, Title: "check output", Done: true})

	got := buf.String()
	want := "#7 [DONE] check output\n"

	fmt.Println(got == want) // true
}

Теперь аккуратный минимальный пример unit‑теста. Он намеренно маленький: без таблиц кейсов и прочих «взрослых» вещей — только суть. Файл обычно называют task_test.go:

package main

import (
	"bytes"
	"testing"
)

func TestWriteTask(t *testing.T) {
	var buf bytes.Buffer
	_ = WriteTask(&buf, Task{ID: 1, Title: "t", Done: false})

	if buf.String() != "#1 [TODO] t\n" {
		t.Fatalf("unexpected output: %q", buf.String())
	}
}

Смысл тот же: мы не трогаем консоль, не ловим stdout, не пишем хитрые перехваты. Мы просто передали функции другой io.Writer. И именно поэтому дизайн «функция пишет в io.Writer» считается таким практичным: он не только про гибкость вывода, но и про проверяемость.

Схема: один рендер — разные назначения

Когда вы много раз видите fmt.Println внутри каждой функции, кажется, что «так и надо». Но в реальных проектах это быстро превращается в шум: функции начинают делать слишком много. Буферы помогают разделить роли: одни части кода формируют вывод, другие — решают, куда его отправить. Это упрощает повторное использование и делает поведение предсказуемым.

flowchart TD
    A[WriteTask / WriteTasks] -->|пишет| W[io.Writer]

    W --> O[os.Stdout
показать пользователю] W --> B[bytes.Buffer
проверить в тесте] W --> S[strings.Builder
получить строку]

Ключевая идея: одна и та же функция WriteTasks подходит всем трём сценариям, потому что она зависит не от конкретного места вывода, а от контракта io.Writer.

8. Типичные ошибки при работе с bytes.Buffer и strings.Builder

Ошибка №1: печатать внутри бизнес‑логики вместо записи в io.Writer.
Часто новичок делает fmt.Println в функции, которая должна “просто посчитать” или “просто сформировать текст”. В итоге код невозможно нормально переиспользовать: он всегда печатает в консоль. Лекарство простое: если функция отвечает за вывод, пусть принимает w io.Writer. Если функция отвечает за вычисления — пусть возвращает значения, а печать останется на границе программы.

Ошибка №2: передавать strings.Builder не как &b, а как b.
Иногда кажется, что «ну это же переменная, чего мелочиться». Но интерфейсы в Go завязаны на набор методов, и часто io.Writer реализует именно указатель на strings.Builder. Если компилятор ругается, что strings.Builder не реализует io.Writer, почти всегда вы забыли амперсанд: нужно &b.

Ошибка №3: читать из bytes.Buffer и удивляться, что данные “пропали”.
Если вы вызвали Read, буфер ведёт себя как поток: прочитанное считается потреблённым. После этого buf.String() покажет остаток. Если вам нужно «посмотреть, что внутри», используйте String()/Bytes() без Read, либо работайте с копией данных.

Ошибка №4: сравнивать “ожидаемый вывод” и “полученный вывод”, забыв про \n.
Это один из самых частых багов в проверках. fmt.Fprintln добавляет перевод строки, fmt.Fprint — нет. Визуально в терминале всё выглядит похоже, а строковое сравнение падает. Привыкайте явно видеть \n в ожидаемой строке и при необходимости печатать строки через %q, чтобы увидеть скрытые символы.

Ошибка №5: копировать Builder/Buffer после начала использования.
На практике копирование таких структур редко нужно, а иногда может дать странные эффекты (например, два значения начинают “делить” внутренности или вы получаете некорректное поведение). Простое правило для старта: храните bytes.Buffer и strings.Builder в одной переменной, передавайте указатель (&buf, &b) и не делайте присваиваний вида b2 := b после записей.

1
Задача
Go SELF, 26 уровень, 1 лекция
Недоступна
Привет без плюса
Привет без плюса
1
Задача
Go SELF, 26 уровень, 1 лекция
Недоступна
Карточка локации
Карточка локации
1
Задача
Go SELF, 26 уровень, 1 лекция
Недоступна
Строка чека
Строка чека
1
Задача
Go SELF, 26 уровень, 1 лекция
Недоступна
Список задач
Список задач
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ