Комментарии 16
Статья громадная, но и ошибок с неточностями в ней тоже хватает. Вот первая порция.
S (single responsibility principle, принцип единственной ответственности) — определенный класс/модуль должен решать только определенную задачу, максимально узко но максимально хорошо (своеобразные UNIX-way).
SRP вообще не про это. Читайте первоисточник (дядю Боба), хватит уже повторять эту чушь. Цитата из его книжки "Чистая архитектура": "Модуль должен отвечать за одного и только за одного актора.", где актор определён как "группа, состоящая из одного или нескольких лиц, желающих данного изменения". Иными словами, SRP - про то, что требования к каждому "модулю" (цитата: "связному набору функций и структур данных") должны поступать от одного актора. И пример нарушения SRP в книжке рассматривает ситуацию, когда требования к одному куску кода поступают одновременно и от отдела бухгалтерии и от отдела работы с персоналом - и, разумеется, требования одних не учитывают (а то и конфликтуют с) требования других.
D (dependency inversion principle, принцип инверсии зависимостей) … Таким образом целевая реализация опирается только на интерфейсы (не зависит от реализаций) и соответствует принципу под буквой S
Никакой связи между D и S нет.
Структурные типы (с методами) служат тем же целям, что и классы в других языках. Так же следует упомянуть что структура определяет состояние.
Методы можно объявить на любом типе данных, хоть на bool
- обычно для этого действительно используются структуры, как наиболее гибкий вариант, но неправильно говорить, что это обязательно должны быть структуры.
встраивание (называемое "анонимным", так как Foo в Bar встраивается не под каким-то именем, а без него)
На самом деле Foo встраивается в Bar не без имени, а под именем совпадающим с названием типа, т.е. Foo. Анонимность заключается не в отсутствии имени поля, а в том, что имя поля можно не указывать при обращении к полям/методам встроенного типа.
а так интерфейс это "ссылочный" тип (на самом деле в Go нет ссылок, но есть указатели) — то и структуры мы инициализировали указателями
Это полностью некорректно.
Во-первых, в Go ссылки есть - через пакет unsafe.
Во-вторых, интерфейс не является ссылочным типом (хоть технически и состоит из пары указателей) и не работает как ссылочные типы (мы не можем внутри функции изменив свой аргумент интерфейсного типа изменить переданное в функцию значение).
В-третьих, в значение интерфейсного типа можно положить не указатель.
псевдо-конструктор NewFoo
Несмотря на то, что в Go нет специальной сущности "конструктор" и создавать объекты можно несколькими способами, на мой взгляд функцию, чьё основное назначение это создание объекта определённого типа, вполне корректно называть конструктором, без "псевдо".
При преобразовании слайса байт в строку (str := string(slice)) или обратно (slice := []byte(str)) — происходит копирование массива (со всеми следствиями).
Это хоть и корректная, но неполная информация - есть возможность сделать строку из среза байт без копирования, вот пример из стандартной библиотеки: (*strings.Builder).String() (но изменение исходного среза байт изменит и строку).
На стеке можно разместить массив объемом 10 MB.
...
Слайсы до 64 KB могут быть размещены на стеке.
Откуда такая информация? (Я уже молчу о том, что любой срез это ровно 24 байта.)
в Go массивы передаются по значению
...
Слайсы передаются "по ссылке" (фактически будет передана копия структуры slice со своими len и cap, но указатель на массив array будет тот-же самый).
Это описывает эффект, но не суть происходящего, и только запутывает. В Go всё передаётся "по значению". Вопрос только в том, что это за значение: в случае массива это весь массив, в случае среза это два числа и ссылка на массив, в случае мапы это одна ссылка, в случае указателя на что угодно это одна ссылка… Поэтому не стоит говорить, что "слайсы передаются по ссылке" - слайс состоит из трёх значений и только одно из них является ссылкой, поэтому "по ссылке" передаётся только "часть" слайса. А чтобы действительно передать слайс "по ссылке" нужно явно передать указатель на слайс.
в момент изменения cap слайса всегда происходит копирование данных массива
Далеко не всегда - при уменьшении cap никакого копирования не происходит. https://go.dev/play/p/rDLV1t5-dWr
Спасибо за ваш комментарий! Не со всем согласен, кое-что перепроверю, но за зашу обратную связь и развернутую мысль - огромное спасибо!
Спасибо за столь развернутый комментарий. Хотелось бы почитать вторую и другие порции исправленных неточностей (на ваш взгляд), указанных в статье.
Вторая часть (ещё где-то треть статьи осталось вычитать).
Что будет в map, если не делать make или short assign?
Будет паника (например — при попытке что-нибудь в неё поместить), так как любые "структурные" типы (а мапа как мы знаем таковой является) должны быть инициализированы для работы с ними.
Из не инициализированной мапы можно без проблем читать любые ключи (получим zero-значение). Можно получить len()
. Можно сравнить с nil
. Если я ничего не забыл, то паника будет не "например", а исключительно при записи в мапу, всё остальное можно делать и с не инициализированной.
По большому счёту, RWMutex это комбинация из двух мьютексов.
Разве что по очень-очень большому. На двух мьютексах RWMutex реализовать невозможно в принципе, тут плюс к мьютексу нужны семафоры.
Какие типы каналов существуют?
В этом разделе информация не то, чтобы некорректная, но она довольно хаотична и скорее сбивает с толку, чем помогает. Например:
Буферизованные каналы работают как асинхронные только пока не заполнится буфер, после чего они превращаются в тыкву (синхронные) пока в буфере не появится свободное место (если появится).
Помимо записи в закрытый канал панику вызовет и повторное закрытие канала.
Через закрытый канал невозможно будет передать или принять данные
Из буферизованного закрытого канала вполне можно вычитывать данные, отправленные в него до закрытия.
проверить открытость канала можно используя
val, isOpened := <- channel
Это не просто проверка на открытость, это ещё и чтение. Более того, если канал закрытый, но буферизованный и содержит значения - эта проверка не сработает пока не будут вычитаны все значения из буфера.
Используя буферизованный канал и цикл
for val := range c { ... }
мы можем читать с закрытых каналов
range
ничем принципиальным от обычной операции чтения из канала не отличается - ни буферизованность, ни закрытость не имеют никакого значения. Единственное отличие range
- при попытке чтения из закрытого канала в котором нет значений цикл будет прерван.
c := make(<-chan int)
— только для чтенияc := make(chan<- int)
— только для записи
Примеры не имеют смысла - использовать (с какой-то либо пользой) созданные таким образом каналы не получится.
Так же можно в сигнатуре принимаемой функции указать однонаправленность канала
Тут дело вовсе не в функции, а в приведении типа из обычного канала в ограниченный чтением или записью, при вызове функции просто выполняется неявное приведение типа, но его можно делать и явно.
Читать "одновременно" из нескольких каналов возможно с помощью
select
Не только читать, но и писать.
P.S. Не поймите неправильно, я уверен, что мои уточнения не станут открытием для автора статьи, основная проблема раздела про каналы скорее в очень неудачных формулировках, нежели фактических ошибках.
Третья часть (финальная).
Go запускает столько тредов, сколько доступно процессорных ядер
Это не так - тредов (M) может быть сколько угодно (по умолчанию - до 10000), т.к. многие из них могут быть заблокированы в каких-то системных вызовах (или вызовах cgo, или из-за runtime.LockOSThread
).
Один из вариантов — это пристрелить main (шутка).
Завершение main не обязательно приводит к завершению других горутин - если из main вызвать runtime.Goexit()
то другие горутины продолжат работать.
Важно — вызывать функцию отмены контекста должна только та функция, которая его создает.
Это не так - вызывать её может кто угодно, передача кому-то этих cancel-функций часто оказывается полезной. Типичный пример: конструктор объекта запускает кучку вспомогательных для создаваемого объекта горутин, которые необходимо остановить когда на объекте будет вызван метод вроде Close()
, и удобнее всего это реализовать создав cancel-функцию в конструкторе и сохранив её внутри объекта, для вызова из метода Close()
.
Лучше всего использовать функции для помещения/извлечения данных из контекста (так как "в нём" они храняться как interface{})
Функции нужны не ради приведения interface{}
к нужному типу, а для того, чтобы можно было использовать не экспортируемое значение для ключа внутри контекста - это защищает от конфликтов по ключу с другими пакетами и от неконтролируемого изменения значений принадлежащих этому пакету другими.
Так же предпочитаю писать тесты, провоцирующие гонку. Код в этом случае будет работать значительно медленнее, но для этапа тестирования это и не так важно.
IMO это плохая идея, не стоит давать такие советы. При наличии нормального покрытия тестами go test -race
находит большинство проблем, необходимости писать для этого специальные тесты, как правило, нет. Замедление же тестов - это серьёзная проблема, потому что медленные тесты разработчики избегают запускать, а тесты нужно запускать часто, чтобы обнаруживать проблемы сразу же после внесения некорректных изменений.
//go:linkname
Спасибо! В статье всё-таки попалось что-то, чего я не знал. :)
Спасибо, классная статья.
Освежил некоторые моменты.
Важной особенностью является то, так как "под капотом" у слайса лежит указатель на массив — при изменении значений слайса они будут изменяться везде, где слайс используется (будь то присвоение в переменную, передача в функцию и т.д.) до момента, пока размер слайса не будет переполнен и не будет выделен новый массив для его значений (т.е. в момент изменения
cap
слайса всегда происходит копирование данных массива):
Я прям не сразу понял как такого добиться, а потом каааак понял.
Вот это грабли.
Мне кажется архитекторы языка переупоролись в "простоту" и "интуитивную понятность", в итоге такое поведение является интуитивно понятным лишь в каких-то совсем простых кейсах, а шаг влево-шаг вправо и всё надо понимать как сделано под капотом.
Увеличении размера слайса (метод growslice
) использует другую стратегию начиная с 1.18
Переключение между двумя Горутинами — супер дешевое,
O(1)
, то есть, не зависит от количества созданных горутин в системе. Всё, что нужно сделать для переключения, это поменять 3 регистра —Program counter
,Stack Pointer
иDX
.
Напомните плз, что с остальными регистрами происходит?
Могу предположить такую реализацию: код языка компилируется так, что в моменты возможного переключения на другую горутину все значения из регистров уже находятся в стеке горутины. То есть в специальных точках программы гарантируется независимость от значений регистров общего назначения. В таком случае ими и правда можно пожертвовать.Но ИМХО не совсем честно называть это O(1).
Хорошая статья, спасибо. Узнал для себя много нового, хотя язык вроде как "простой" и казалось что ничего сильно нового быть там не должно.
Слайс, насколько я понимаю, это аналог std::vector из С++. Непонятно почему его назвали "срезом", мне интуитивно казалось что "срез" больше подходит к какому-то range или view - невладеющей структуре, ссылающейся на часть массива или строки.
"Захват переменной цикла" меня конечно сильно удивил. Но проверил - да, действительно, переменная именно захватывается, хотя казалось бы - это всего лишь передача адреса. После С/С++ это может быть источником граблей.
var out []*int
for i := 0; i < 3; i++ {
out = append(out, &i)
}
println(*out[0], *out[1], *out[2]) // 3 3 3
Так срез - это именно невладеющая структура, ссылающаяся на часть массива. В простых случаях при создании среза создаётся и массив, который никем кроме этого среза не используется, что создаёт между ними связь 1-к-1 и иллюзию того, что массив является частью среза, плюс срез начинается в начале массива. Но это - просто частный случай. Никто не мешает иметь несколько срезов, которые указывают на разные части одного массива, в т.ч. пересекающиеся между собой.
После С/С++ это может быть источником граблей.
эээ… если мы в си положим в массив указатель на локальную переменную, а обращаться по этому указателю мы будет за пределами области жизни переменной, то это будут действительно грабли.
#include <stdio.h>
int *out[3];
void a() {
for (int i = 0; i < 3; i++) {
out[i] = &i;
}
}
int b() {
int j = 77;
return j;
}
int main () {
a();
b();
printf ("%d, %d, %d\n", *out[0], *out[1], *out[2]);
return 0;
}
Для самих же переменных, что используются внутри горутин память берётся с хипа (ограничены только размером "физического" хипа, т.е. объемом памяти сколько есть на машине).
Не нашел подтверждения этого утверждения. Разве аллокация памяти в горутинах происходит не в самом стеке этой горутины? А расширение стэковой памяти как раз идет за счет запроса выделения из общего хипа.
Кмк, пропущено или не акцентирована несколько интересных моментов:
Структуры - индексы для map. В частности map[struct{int,int,int}]AnyItemType способна эффективнее индексировать "трехмерную мапу" привычного вида map[int]map[int]map[int]AnyItemType, т.к. применится только одна функция хеширования индекса, да и все маповые подкапотные структуры становятся существенно проще. Часто требуется, где данные ассоциативны по нескольким измерениям.
В Go нет классического ООП от слова совсем. Вместо ООП предлагается несколько отдельных подходов: а) встраивание объектов; б) интерфейсы как контракты реализаций; в) пакеты г) "Си с классами" - возможность создания методов к типам.
Ограничение на циклическое использование пакетов в Go практически принуждает любую архитектуру приложения становится "чистой"..
Вопросы и ответы для собеседования Go-разработчика