JavaRush /Курсы /Go SELF /Направление каналов в API — chan<- и <-chan

Направление каналов в API — chan<- и <-chan

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

1. Зачем в Go есть направление каналов

Когда начинаешь писать конкурентный код, первое желание — «пусть все функции получают chan T, чтобы могли и читать, и писать: так универсальнее». Это похоже на ситуацию, когда вы даёте всем сотрудникам мастер‑ключ от офиса: удобно… до первой странной истории с пропавшими печеньками и “я вообще не трогал ваш прод”.

Направление каналов — это способ на уровне типов сказать: «эта функция только отправляет» или «эта функция только читает». В Go это считается нормальной практикой: меньше свободы в API — меньше случайных ошибок. И это очень в духе Go‑философии про “общаемся через передачу данных, а не через общий доступ”.

Типы каналов и “права доступа”

На человеческом уровне канал — это один объект, но на уровне типов Go различает «права доступа» к нему. Представьте, что канал — это дверь, а направление — это табличка: «только вход» или «только выход». Дверь одна и та же, но табличка не даёт вам сделать глупость и пытаться “выйти через вход”.

В Go есть три формы:

Тип канала Можно отправлять (ch <- v) Можно получать (<-ch) Можно close(ch)
chan T
да да да
chan<- T (send-only) да нет да
<-chan T (receive-only) нет да нет

Ключевой момент: направленность — это часть типа, а не «режим работы канала в рантайме». Рантайм не хранит “флаг направления”; направление нужно компилятору, чтобы заранее запретить неправильные операции.

Давайте посмотрим на микро‑пример: компилятор реально вас защищает.

package main

import "fmt"

func main() {
	ch := make(chan int, 1)

	var out chan<- int = ch
	var in <-chan int = ch

	out <- 10
	fmt.Println(<-in) // 10
	// in <- 20           // compile error: cannot send to receive-only channel
	// fmt.Println(<-out) // compile error: cannot receive from send-only channel
}

Обратите внимание: мы создали один канал ch, но можем «раздать» его разным частям программы с разными правами. И это уже похоже на нормальный контракт, а не на «берём всё и надеемся, что никто не нажмёт красную кнопку».

2. Сужение прав в сигнатурах функций

В реальном коде направленные каналы чаще всего живут в сигнатурах функций. Смысл простой: если функция по задумке только отправляет, пусть она принимает chan<- T. Если только читает, пусть принимает <-chan T.

Почему это работает хорошо? Потому что сигнатура становится мини‑документацией: вы видите её и сразу понимаете роль функции, даже не читая тело. Это особенно приятно через пару недель, когда вы открыли свой код и думаете: «кто написал это чудо, и почему оно ещё работает?». Спойлер: написали вы.

Вот базовый шаблон «производитель → потребитель».

package main

import "fmt"

func produce(out chan<- int) {
	out <- 1
	out <- 2
	close(out)
}

func consume(in <-chan int) {
	for v := range in {
		fmt.Println("got:", v) // got: 1 / got: 2
	}
}

func main() {
	ch := make(chan int)
	go produce(ch)
	consume(ch)
}

Здесь сразу видно три вещи.

  • Во‑первых, produce физически не может «случайно» прочитать из канала.
  • Во‑вторых, consume не сможет «случайно» отправить туда значение и тем самым поломать протокол.
  • В‑третьих, закрытие (close(out)) живёт у отправителя, и это соответствует правилу «закрывает отправитель».

Ещё один важный момент: двунаправленный chan T можно передать туда, где ожидают chan<- T или <-chan T. Это “сужение возможностей” происходит автоматически при присваивании/передаче аргумента. А вот обратно «расширить права» нельзя: это и есть защита.

4. Проектирование API: возвращаем поток и закрываем канал

Возвращаем receive-only канал

Иногда хочется сделать API, где наружу отдаётся «поток значений», но наружный код не должен туда ничего отправлять и точно не должен закрывать канал. В таких случаях очень удобно возвращать из функции receive-only канал <-chan T.

На уровне ощущений это похоже на автомат с кофе: вы можете получать кофе (читать из канала), но вы не можете “положить внутрь” свой чай (послать в канал) и вы не можете закрыть автомат на ключ (close). Контракт простой и понятный.

Пример: функция создаёт канал, запускает горутину и возвращает <-chan int.

package main

import "fmt"

func numbers(n int) <-chan int {
	ch := make(chan int)
	go func() {
		for i := 1; i <= n; i++ {
			ch <- i
		}
		close(ch)
	}()
	return ch
}

func main() {
	for v := range numbers(3) {
		fmt.Println(v) // 1 потом 2 потом 3
	}
}

Почему это классный стиль?

  • Потому что снаружи невозможно сломать протокол: у вас нет доступа на отправку.
  • Плюс такой API читается как обещание: “я возвращаю поток значений, читайте его до конца”.

Кстати, такой паттерн регулярно встречается в примерах и обсуждениях Go‑подходов к потокам данных: фильтры/пайплайны часто делают именно так — возвращают <-chan как результат.

Кто может close, и почему это важно

С close начинаются самые драматичные серии в «Санта‑Барбаре конкурентности», потому что close — это не “убрать канал”, а “объявить, что отправок больше не будет”. Если закрывает “не тот”, то отправитель может попробовать отправить ещё одно значение — и получить panic. Это уже не зависание, это прямо взрыв с дымом.

Направленные типы помогают и тут: если функция принимает <-chan T, она физически не сможет закрыть канал — компилятор запретит. Это приятно, потому что закрытие — ответственность отправителя, и типы подсказывают правильную архитектуру.

Посмотрим на маленький пример, где компилятор не даёт вам нарушить договор.

package main

func consume(in <-chan int) {
	// close(in) // compile error: cannot close receive-only channel
	_ = in
}

func main() {}

А вот отправитель, который принимает chan<- int, закрывать может (и должен, если это поток с окончанием):

package main

func produce(out chan<- int) {
	out <- 1
	close(out)
}

func main() {}

В результате получается аккуратная дисциплина: “читатели читают”, “писатели пишут и закрывают”. И меньше шансов, что кто-то «помог» и закрыл канал раньше времени.

5. Мини‑рефакторинг: поток задач и подсчёт статистики

Чтобы это не осталось абстракцией про «каналы с числами», давайте привяжем направленные каналы к нашему учебному приложению. По курсу мы уже моделировали задачи (task/todo), хранили их, выводили в CLI/HTTP, делали сортировки, работали с ошибками. Теперь представим маленькую внутреннюю потребность: быстро посчитать статистику по задачам, не ломая остальной код.

Идея будет такой: у нас есть функция, которая «стримит» задачи в канал (производитель), и есть функция, которая этот поток читает и считает, сколько задач выполнено (потребитель). Никаких select, никаких таймаутов — только чистый канал + range.

Начнём с модели задачи (упрощённо):

package main

type Task struct {
	ID   int
	Text string
	Done bool
}

Теперь сделаем “производителя”: он получает список задач и отправляет их в канал. Обратите внимание на сигнатуру: out chan<- Task.

package main

func streamTasks(tasks []Task, out chan<- Task) {
	for _, t := range tasks {
		out <- t
	}
	close(out)
}

И “потребителя”: он только читает <-chan Task и считает done.

package main

func countDone(in <-chan Task) int {
	done := 0
	for t := range in {
		if t.Done {
			done++
		}
	}
	return done
}

Склеим это в main, чтобы увидеть общую картинку:

package main

import "fmt"

func main() {
	tasks := []Task{
		{ID: 1, Text: "read Go spec", Done: false},
		{ID: 2, Text: "fix bug", Done: true},
		{ID: 3, Text: "drink tea", Done: true},
	}

	ch := make(chan Task)

	go streamTasks(tasks, ch)

	done := countDone(ch)
	fmt.Println("done:", done) // done: 2
}

С точки зрения протокола здесь всё красиво: отправитель (в горутине) отправляет все значения и закрывает канал; получатель читает range и завершается автоматически. И самое важное для нашей темы: сигнатуры фиксируют роли. Даже если через месяц вы вынесете streamTasks и countDone в разные файлы/пакеты, по типам уже видно, кто что делает.

Для наглядности — маленькая схема (не “магия”, просто визуализация того, что мы уже написали):

flowchart LR
    A["streamTasks(tasks, out chan<- Task)"] -->|out <- Task| CH[(chan Task)]
    CH -->|range in <-chan Task| B["countDone(in <-chan Task)"]
    A -->|"close(out)"| CH

Если вы сейчас думаете: «а почему бы countDone не принимать chan Task, оно же тоже работает?» — да, будет работать. Но chan Task говорит “я могу и писать, и читать”, а <-chan Task говорит “я только читаю”. И вот это “только” — наша страховка.

6. Типичные ошибки при использовании chan<- и <-chan

Ошибка №1: везде использовать chan T “потому что так проще”.
На первых шагах это кажется удобным: меньше думать, меньше символов. Но вы платите за это тем, что теряете проверку протокола компилятором. Через неделю в кодовой базе появляется функция‑монстр, которая и читает, и пишет, и закрывает, и «чуть‑чуть логирует», а потом вы ловите зависание и начинаете подозревать квантовую механику.

Ошибка №2: пытаться “расширить права” обратно, если у вас <-chan T.
Иногда новички получают <-chan T из функции, а потом хотят “чуть‑чуть отправить туда значение”. Так нельзя, и это правильно: если API вернул вам receive-only, значит отправка — не ваша ответственность. Если очень хочется отправлять, значит контракт функции должен быть другим, а не “давайте обманем типы”.

Ошибка №3: закрывать канал не там, где закончились отправки.
Даже с направленными типами можно ошибиться логически: например, закрыть out до того, как закончился цикл отправки (или закрыть в середине по ошибке). Результат неприятный: следующая отправка даст panic. Лечится дисциплиной: close(out) должен стоять в том месте, где вы точно знаете, что отправок больше не будет (обычно после цикла).

Ошибка №4: смешивать в одном канале разные смыслы и пытаться “рулить” протоколом через значения.
Когда появляется поток, возникает соблазн: “а давайте передадим Task{ID:0} как признак конца”. Это плохо, потому что вы начинаете конфликтовать с zero values и усложняете чтение. Каналы в Go уже имеют встроенный сигнал завершения: close + range + ok. Пользуйтесь этим, а не изобретайте «тайные рукопожатия».

Ошибка №5: думать, что направленные каналы — это про производительность.
Нет, они не ускоряют программу и не делают каналы “быстрее”. Направление — это про дизайн API и снижение числа ошибок. Это как ремень безопасности: он не делает машину быстрее, зато делает поездку менее драматичной.

1
Задача
Go SELF, 66 уровень, 4 лекция
Недоступна
Счётчик награды
Счётчик награды
1
Задача
Go SELF, 66 уровень, 4 лекция
Недоступна
Печатник ярлыков
Печатник ярлыков
1
Задача
Go SELF, 66 уровень, 4 лекция
Недоступна
Фильтр бонусов
Фильтр бонусов
1
Задача
Go SELF, 66 уровень, 4 лекция
Недоступна
Двойная рассылка
Двойная рассылка
1
Опрос
Конкурентность 2: channels, 66 уровень, 4 лекция
Недоступен
Конкурентность 2: channels
Конкурентность 2: channels
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ