Pull to refresh

Comments 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 слайса всегда происходит копирование данных массива):

Я прям не сразу понял как такого добиться, а потом каааак понял.
Вот это грабли.

Мне кажется архитекторы языка переупоролись в "простоту" и "интуитивную понятность", в итоге такое поведение является интуитивно понятным лишь в каких-то совсем простых кейсах, а шаг влево-шаг вправо и всё надо понимать как сделано под капотом.

Да, это грабли ещё те, т.к. всё передается по значению, в т.ч. и слайсы. Append() к параметру-слайсу внутри функции может иметь неожиданные последствия, если функция не возвращает этот параметр обратно.

Увеличении размера слайса (метод growslice)  использует другую стратегию начиная с 1.18

Переключение между двумя Горутинами — супер дешевое, O(1), то есть, не зависит от количества созданных горутин в системе. Всё, что нужно сделать для переключения, это поменять 3 регистра — Program counterStack 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;
}

Для самих же переменных, что используются внутри горутин память берётся с хипа (ограничены только размером "физического" хипа, т.е. объемом памяти сколько есть на машине).

Не нашел подтверждения этого утверждения. Разве аллокация памяти в горутинах происходит не в самом стеке этой горутины? А расширение стэковой памяти как раз идет за счет запроса выделения из общего хипа.

Кмк, пропущено или не акцентирована несколько интересных моментов:

  1. Структуры - индексы для map. В частности map[struct{int,int,int}]AnyItemType способна эффективнее индексировать "трехмерную мапу" привычного вида map[int]map[int]map[int]AnyItemType, т.к. применится только одна функция хеширования индекса, да и все маповые подкапотные структуры становятся существенно проще. Часто требуется, где данные ассоциативны по нескольким измерениям.

  2. В Go нет классического ООП от слова совсем. Вместо ООП предлагается несколько отдельных подходов: а) встраивание объектов; б) интерфейсы как контракты реализаций; в) пакеты г) "Си с классами" - возможность создания методов к типам.

  3. Ограничение на циклическое использование пакетов в Go практически принуждает любую архитектуру приложения становится "чистой"..

Sign up to leave a comment.

Articles