Русский
Русский
English
Статистика
Реклама

Дизайн кода

Из песочницы Преимущества интерфейсов в GO

06.07.2020 20:09:26 | Автор: admin

Преимущества интерфейсов в GO


В языке GO интерфейсы отличаются от других языков. Они немного лучше чем в других популярных языках с точки зрения дизайна. В этой статье я постараюсь объяснить почему.
Я расскажу о преимуществах, приведу примеры и рассмотрю некоторые вопросы, которые могут возникнуть при использовании интерфейсов.


В чем особенность интерфейсов в GO?


В большинстве языков вы описываете один интерфейс, и реализуете их в других местах явно, указывая, что вы реализуете именно их. Но в GO это не так. Вам не нужно указывать явно, что вы реализуете его в своей структуре. Если у структуры есть все функции и они имеют одинаковую сигнатуру с интерфейсом, то она уже его реализует.


Какие преимущество дает эта особенность?


Приватный интерфейс


Интерфейсы удобно описывать внутри модуля. Объясню почему это хорошо. Предположим вы пишите пакет. По-хорошему он должен минимально зависеть от других пакетов, и для этого вы отгораживаетесь от них интерфейсом. Это позволит вам тестировать ваш пакет изолированно и в случае необходимости подменить внешнюю библиотеку. Но этот интерфейс может быть приватным, т.е. другие пакеты о нем ничего не знают.


В конкретный момент нужны конкретные методы от какого-то внешнего объекта, и для этого достаточно иметь интерфейс только с этими конкретными методами. Вам не нужно искать подходящие интерфейсы под создаваемый модуль из внешних или общих пакетов.


Рассмотрим пример.


Я пишу пакет, отвечающий за авторизацию пользователя. В нем необходимо обращаться к репозиторию пользователя. Нам нужны только несколько методов, опишем их в интерфейсе:


package authimport (   "gitlab.com/excercice_detection/backend")type userRepository interface {   FindUserByEmail(email string) (backend.User, error)   AddUser(backend.User) (userID int, err error)   AddToken(userID int, token string) error   TokenExists(userID int, token string) bool}// Auth сервис авторизацииtype Auth struct {   repository userRepository   logger     backend.Logger}// NewAuth создает объект авторизацииfunc NewAuth(repository userRepository, logger backend.Logger) *Auth {   return &Auth{repository, logger}}// Autentificate Проверяет существование токена пользователяfunc (auth Auth) Autentificate(userID int, token string) bool {   return auth.repository.TokenExists(userID, token)}

Для примера я показал как используется один из методов, на самом деле они используются все.


В главном методе main создается и используется объект авторизации:


package mainimport (   "gitlab.com/excercice_detection/backend/auth"   "gitlab.com/excercice_detection/backend/mysql")func main() {    logger := newLogger()    userRepository := mysql.NewUserRepository(logger)    err := userRepository.Connect()    authService := auth.NewAuth(userRepository, logger)...

При создании объекта авторизации достаточно передать userRepository, у которого реализованы все методы, которые есть в интерфейсе, а пакет mysql при этом ничего не знает об интерфейсе, описанном в сервисе авторизации. Он и не должен об этом знать. Нет лишних зависимостей. Код остается чистым.


В других языках программирования вам бы пришлось описывать интерфейс в общем модуле. И указывать в классе репозитория, что он реализует этот интерфейс. А в классе авторизации использовать его. Хотя на самом деле об этом интерфейсе достаточно знать только модулю авторизации, потому что он нужен только ему.


Если вы передадите объект, который не реализует нужный интерфейс, вы получите ошибку на этапе компиляции.


Такой интерфейс удобно расширять


Если вам в будущем пригодятся другие методы из репозитория, вы можете просто добавить их в этот приватный интерфейс. Он немного усложниться, но только внутри этого модуля. Вам не нужно докидывать их в неком общем интерфейсе, а затем описывать методы везде, где он реализуется.


Тесты


В тестах тоже удобно использовать этот интерфейс. Достаточно подменить методы, которые будут использоваться в модуле. Не нужно подменять ничего лишнего.


Пример мока:


type userRepositoryMock struct {   user         backend.User   findUserErr  error   addUserError error   addUserID    int   addTokenErr  error   tokenExists  bool}func (repository userRepositoryMock) FindUserByEmail(email string) (backend.User, error) {   return repository.user, repository.findUserErr}func (repository userRepositoryMock) AddUser(backend.User) (userID int, err error) {   return repository.addUserID, repository.addUserError}func (repository userRepositoryMock) AddToken(userID int, token string) error {   return repository.addTokenErr}func (repository userRepositoryMock) TokenExists(userID int, token string) bool {   return repository.tokenExists}

Далее, в тестах, userRepositoryMock можно подсунуть вместо обычного userRepositorу, подставляя нужные значения, которые должна вернуть функции.


Как понять, кто на самом деле реализует метод, используемый из интерфейса?


Кажется, что закрываясь интерфейсом и не указывая явно кто его реализует, мы теряем знание о том, как на самом деле работает нужная функция. Но, поскольку GO является строго типизированным языком, то узнать кто реализует метод достаточно просто. Например IDE GoLand умеет так делать.


Чтобы убедиться, что структура реализует интерфейс, достаточно запустить компилятор. Он покажет, каких методов не хватает.


Как найти места, где используются реализованные методы, если они закрыты интерфейсом?


Ответ тот же самый. Это тоже легко поддается статическому анализу. Если ваша IDE не может найти реализации, то это её недостаток, а не интерфейсов. Если IDE развивается, то в ближайшем будущем эта функция должна появиться.


Заключение


Интерфейсы в GO позволяют сделать код немного проще. Скрытие лишних зависимостей, простота в расширении кода и легкое тестирование это те преимущества, которыми обладают интерфейсы в этом языке. Я не утверждаю, что это большое преимущество. Но если вы разрабатываете большой проект, это важно.


Язык GO кажется подходящим инструментом для развития больших проектов. И интерфейсы это одна из тех фич, которая сделана удачно.

Подробнее..

Заметки по книге Философия разработки ПО

02.09.2020 20:13:34 | Автор: admin


Возможно, вы понимаете как писать хороший код, как придерживаться хорошего дизайна. Но структурировать эти знания не получается. Книга Джона Оустерхаута A philosophy of software design может помочь исправить это.


Она позволила структурировать знания, накопленные мною за 3 года работы разработчиком и исправить многие ошибки в понимании хорошего дизайна кода. Она помогла четче прояснить для себя, что же это такое.


Здесь я хочу сделать краткое изложение всей книги в виде заметок, которые я записывал, когда читал её.


О чем книга


Эта книга о том, как писать простой код. Автор считает, что именно простота является главным признаком хорошего дизайна. Следовательно, главной проблемой он считает борьбу со сложностью кода.


Он выделяет 2 пути борьбы со сложностью:


  1. Писать более понятный код. За счет хороших комментариев, правильного именования переменных, простого интерфейса и реализации.
  2. Инкапсулировать сложность. Другими словами скрывать неважную информацию.

Далее я буду использовать термин Модуль. Модулем может быть класс, функция, внешнее API и т.д. В том же значении этот термин использует автор книги.


Что такое сложность


Чтобы бороться со сложностью, нужно хорошо прояснить для себя, а что же это такое.
Симптомы сложности:


  1. Небольшие правки в функциональности требуют изменений кода во многих местах.
  2. Большая когнитивная сложность. Разработчику приходится изучить большое количество информации и держать многое в памяти, чтобы понять, как работает код.
  3. Не очевидно, что необходимо менять в коде, чтобы изменить функционал.

Главные причины сложности:


  1. Большое количество зависимостей
  2. Неочевидные вещи в коде:
    • Общие названия переменных
    • несколько целей у переменных
    • плохая документация
    • неочевидные зависимости (или утечка зависимостей)

Ещё важно отметить, что сложность имеет свойство накапливаться. Мы не замечаем, как сложность кода повышается. Когда мы откладываем рефакторинг кода или не заботимся о его дизайне, незаметно для себя можем прийти в такое состояние, когда код ужасно сложный и очень тяжело добавить новые фичи.


По этой причине автор выделяет 2 подхода программирования, он называет их:


  • тактическое
  • стратегическое

В тактическом программировании мы думаем только о решении поставленной задачи, не задумываясь о дизайне кода. Мы решаем её первым пришедшим в голову способом, не думая о том, что возможно другое решение, которое проще для понимания.


Результатом такого подхода является тактическое торнадо легаси код, оставленный вашими коллегами в стремлении сделать задачи как можно быстрее. Этот код очень трудно понять, даже им самим.


Стратегическое программирование мы ищем лучшее решение, рассматривая несколько вариантов. Оно состоит из двух условий:


  1. проактивности мы учитываем изменения и потребности в будущем и думаем о документации и понятности кода.
  2. реактивности исправляем очевидные проблемы в старом коде, а не только пишем новый.

Важно отметить, что в стратегическом программировании невозможно сразу написать полностью правильный код. В любом случае некоторые проблемы прояснятся в будущем. Поэтому важно исправлять эти проблемы в уже написанном коде сразу, как только они были замечены.
Многие стартапы могут не согласиться с таким утверждением. Для них критично выпустить фичу как можно быстрее, иначе они просто не смогут существовать. Если стартап находится в такой ситуации, конечно можно пренебречь качеством, но при первой возможности лучше сразу отрефакторить код.


Ниже представлен график отображающий, сколько времени приходится тратить на разработку по мере роста проекта в разных подходах.



Автор предлагает тратить 20% своего времени на исправление старого кода, иначе сложность будет копиться, его понимание будет занимать много времени.


Модули должны быть глубокими


Это означает, что реализация модулей должна быть гораздо больше интерфейса.
Интерфейс говорит о том, что делает модуль, а реализация как она это делает.
В хорошем коде, когда мы создаем или изменяем модуль, нам важно знать интерфейсы используемых внутри модулей и не нужно знать их реализацию. Нам важно знать реализацию только изменяемого модуля.


Поэтому, чем проще интерфейс у используемых модулей, тем проще с ними работать. Чем больше методов, аргументов и нюансов использования имеет модуль, тем сложнее его интерфейс.


Интерфейс может быть:


  1. Формальный это сигнатура, публичные методы, свойства класса и т.д.
  2. Неформальный комментарии к модулю, нюансы работы.

Простота кода сильно зависит от абстракции это упрощение понимания модуля, за счет сокрытия не важной информации. У абстракции могут быть 2 проблемы:


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

Если ваш модуль очень маленький, то скорее всего интерфейс этого модуля больше, чем его функционал.


Важно, чтобы модуль имел простой интерфейс и при этом большую функциональность, для того, чтобы интерфейс скрывал сложность кода. Но при этом не нужно сильно увлекаться сокрытием, иначе можем скрыть важную информацию, и в итоге не будем до конца понимать, как правильно работать с модулем.


Утечка информации


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


  • Интерфейс
  • Через back-door. Знания не описанные в интерфейсе, например, когда о формате файла знают несколько классов, хотя для она важна только для одного. Такая утечка гораздо хуже утечки через интерфейс.
    При обнаружении утечки, следует ответить на вопрос Как изменить модули, чтобы знание влияло только на 1 класс?. Возможно модули стоит объединить в один или вынести информацию наружу и обернуть её в более высокоуровневый модуль.

Временная декомпозиция


В процессе разработки мы часто размещаем информацию, основываясь на временной последовательности её выполнения. Например, если мы модифицируем файл, то можем разбить код на 3 класса: чтение, модификация и перезапись файла. При таком разбиении, в чтении и перезаписи файла нам придется знать о формате файла, тогда возникнет утечка информации.
Модуль должен быть завязан на знании, а не последовательности выполнения. Потому что модули должны быть переиспользуемыми. Модуль может быть нужным для совершенно другой задачи, без привязки к конкретной последовательности выполнения.


Общецелевые модули


Это модули с заделом на будущее, с возможностью использовать где-то ещё.


Преимущество таких модулей в том, что они не сильно привязаны к текущей задаче и склоняют вас делать интерфейс проще и универсальней.


В таким модулях лучше сначала описать интерфейс, а затем реализовать только те методы, которые нужны в текущей задаче.


Вопросы, которые помогут создать общецелевой интерфейс:


  1. Какой самый простой интерфейс покроет все мои нужды?
  2. В скольких ситуациях этот метод будет использован? Если только в одной, то скорее всего вы делаете интерфейс неправильно.
  3. Насколько легко использовать интерфейс в данный момент?

Разные слои, разные абстракции


В хорошо спроектированной системе есть разные слои абстракции кода, более высокоуровневый код использует модули на более низком уровне.


Какие проблемы на разных слоях абстракции могут возникать:


  • Прокинутые методы когда результат выполнения метода просто прокидывается на более высокий уровень, без каких либо обработок. Такой случай, как правило, только усложняет код. Проблема здесь в том, что ответственности разных классов пересекаются.
    Исключением являются методы диспетчеры, которые отвечают за выбор нужного метода для вызова.
    Также, декораторы используют проброшенные методы. Поэтому их стоит использовать осторожно и только в крайнем случае.
  • Прокинутые переменные когда переменные прокидываются вглубь к более низкоуровневым классам без обработок. В такой ситуации возникают следующие проблемы:
    создается зависимость и никак не используется в промежуточных слоях
    требуется изменения множества методов и заставляет усложнить интерфейс каждого.
    Лучшее решения для такой ситуации это использовать DI контейнер. Это не идеальное решение, оно может привести к неочевидным зависимостям, поэтому его следует использовать осторожно. Для того чтобы избежать многих проблем, переменные в контейнере можно делать неизменяемыми (immutable).

Старайтесь не перекладывать ответственность на верхний уровень


Не перекладываете ответственность на пользователей данного модуля насколько это возможно. Решайте как можно больше проблем внутри. (Пользователь это не только человек, но и высокоуровневый модуль, который использует модуль).
Например, мы можем дать возможность настроить параметры модуля снаружи. Но может оказаться такая ситуация, когда параметры окажутся несовместимы, и эту проблему придется решать пользователю. Сначала стоит подумать, возможно ли определить эти параметры автоматически внутри.
Модуль должен выполнять свои обязанности до конца. В конце разработки модуля следует сделать дополнительные усилия и подумать, есть ли возможность скрыть лишние знания от пользователя.


Разделить или объединить


Для улучшения дизайна кода, часто, необходимо либо разделить модуль на несколько, либо наоборот объединить с другим. Чтобы понять, стоит ли объединять, рассмотрим признаки для объединения:


  1. Модули обращаются к общей информации.
  2. Используются совместно. Один нельзя использовать без другого.
  3. Решают общую задачу.
  4. Тяжело понять одну часть кода без другой.
  5. Если после объединения интерфейс упростится.

Разбивать код можно на общецелевой и специализированный. Если в модуле есть часть кода, которая может быть нужна в других задачах, то её стоит выделить отдельно и использовать внутри второго. То же самое с повторами в коде, это признак того, что повторяемая часть это общецелевой модуль.


Специализированный код это та часть кода, которая очевидно нужна только в конкретной задаче.


Если, после разделения метода или выделения подметода, приходится переключаться между ними, чтобы понять как они работают это признак того, что методы разбиты неправильно или их не нужно разбивать. Дочерний метод не должен знать о том, где его используют.


Выделенный метод становится, в каком то смысле, общецелевым, и в перспективе его могут переиспользовать.


Работа с исключениями


Здесь под исключением понимаются не только exception которые выкидываются в коде. Это любые ситуации, которые вызывают необычное поведение системы. Когда что-то происходит не так, как задумывалось.


Исключения добавляют сложность в интерфейсе потому что:


  • одни исключения порождают новые
  • их нужно обрабатывать
  • исключения могут быть не обнаружены, потому что они появляются редко
  • обработка исключений сама может породить новое исключение

Исключение это тоже часть интерфейса. Чем больше исключений у интерфейса, тем он сложнее.


Нужно стремиться скрыть исключение как можно раньше, не перекладывая это на пользователя.


Способы скрыть исключение:


  1. Игнорировать исключение. Принять его за нормальное поведение.
  2. Обработать внутри модуля, не выбрасывая его внаружу.
  3. Обработать множество исключений в одном обработчике, прокидывая через несколько уровней вверх и обработав в одном месте.
  4. Просто прервать программу с ошибкой, когда обрабатывать её бесполезно.

Не нужно стараться прятать ошибку внутри модуля всегда. Иногда бывает важно знать о случившейся ошибке внешнему модулю.


Проектируй дважды


Не стоит реализовывать первую пришедшую идею. Стоит рассмотреть несколько вариантов. Это позволит сэкономить время на переписывании кода.


Вначале стоит подумать про интерфейс. Не стоит рассматривать все плюсы и минусы каждого, достаточно ключевых. Важно подумать об абсолютно разных вариантах, которые принципиально отличаются друг от друга. В процессе размышления может родиться новый, который будет сочетать плюсы обоих.


Стоит подумать о том:


  • какой из них проще
  • является ли вариант более переиспользуемым
  • будет ли реализация более производительной

Если для использования рассматриваемого варианта потребуется доработка верхнеуровневого класса, то это признак того, чтобы отказаться от него.


Рассмотрение вариантов не должно отнимать много времени, в среднем 1-2 часа. Чем более важный и крупным модуль мы хотим разрабатывать, тем больше времени на обдумывание мы можем потратить.


Зачем писать комментарии


Для начала рассмотрим популярные аргументы того, почему не стоит писать код и опровергнем их:


  1. Хороший код самодокументируемый. Этот подход ошибочный, потому что:
    • В коде нельзя дать высокоуровневое описание того, что делает метод или причину того, или иного решения в реализации.
    • Если пытаться упростить реализацию для легкого понимания, то придется разбивать модуль так, что это может усложнить его интерфейс.
    • Если пользователь читает всю реализацию, то ему приходится читать не только важную информацию, но и не важную, из-за чего теряется смысл в абстракции.
    • Некоторые нюансы передаваемых аргументов и свойств нельзя описать в коде.
  2. Нет времени писать комментарии. Отсутствие комментариев вынудит потратить дополнительное время на понимание кода в будущем, из-за чего оно будет потрачено в ещё большем объеме.
  3. Комментарии устаревают и вводят в заблуждение. На самом деле поддержка правильно написанных комментариев не занимает много времени. Это потребуется только если происходят большие изменения в коде.

Главная идея в написании комментариев записать важные мысли разработчика, которые нельзя описать в коде. Это позволит избежать ошибок, на которые попадется разработчик после него. Будут понятны намерения автора. Также это понизит когнитивную сложность.


Как писать комментарии


Разработчик должен понять что делает модуль не читая код. Если он может написать правильный комментарий к коду не читая его, значит комментарий к модулю не нужен.


Я просто перечислю ряд важных советов которые дает автор.


Хороший прием использовать другие слова в комментарии чем в коде.


Описывая переменные, думайте существительными, а не глаголами.


Если комментарии интерфейса вынуждают описывать реализацию, то значит класс или метод слишком сложный. Скорее всего у вас неправильная абстракция кода.


Цель комментариев в реализации дать понимание, что делает код (не как он это делает). Они нужны только для больших и сложных реализаций. Для простой реализации комментарий не нужен. Если разработчик поймет, что делает код, ему будет гораздо легче понять сам код.
Также бывает так, что общая логика размазана по нескольким модулям, например отправка и получение http запроса. Комментировать это трудно, поскольку дублировать комментарии нежелательно.


Для таких случаев лучше завести общий файл с заметками, и писать комментарий туда. Но будет одна проблема комментарии находятся далеко от кода. При изменении кода, будет сложнее поддерживать комментарии.


Комментарии лучше писать вначале


Какие выгоды это дает:


  1. В процессе написания кода вы можете ориентироваться на написанные комментарии.
  2. Комментарии становятся инструментом проектирования дизайна кода. Если вы замечаете, что для интерфейса слишком длинный комментарий, скорее всего ваш интерфейс слишком сложный и лучше придумать что-то попроще.
  3. Раннее комментирование позволяет сформировать хороший дизайн и абстракцию ещё до написания кода.

Именование переменных


Именование это одна из форм документирования.
Правильное именование позволяет:


  • легче находить ошибки
  • уменьшает сложность
  • уменьшает необходимость в комментариях

Имя должно быть:


  • Не слишком общим, например count. Если трудно подобрать полноценное имя, то это признак того, что вы делаете что-то не так. Возможно переменная имеет слишком много назначений. Лучше разбить её на несколько.
  • Консистентным, т.е. такое имя должно использоваться в других местах с таким же назначением, и не использоваться другое имя для такого же назначения.

Консистентность кода


Консистентность означает, что похожие вещи сделаны похожим образом, а непохожие по другому. В чем может проявляться консистентность:


  • наименованиях
  • стиле кода
  • интерфейсе
  • в паттернах (например MVC улучшает консистентность)

Консистентность дает:


  • Быстрое понимание того как работает код, если вы уже знакомы с похожими местами.
  • Уменьшает ошибки. Если похожие места сделаны по разному, то разработчику легче ошибиться, подумав, что здесь также.

Консистентность легко могут нарушить новички в команде, поскольку они не знакомы со всеми договоренностями. Этого можно избежать при помощи:


  • Документации
  • Линтеров и других инструментов, не позволяющих пушить код, не соответствующий принятым стандартам.
  • При изменении кода смотреть, как сделано сейчас.

Старайтесь не менять существующие договоренности, ценность от консистентности может быть выше, чем улучшения, которые предлагается ввести. Перед вводом неконсистентности дайте ответ на 2 вопроса:


  1. Старый подход не позволяет добиться нужного результата?
  2. Новый подход настолько лучше, что хуже тратить время на поддержку старого и все в команде с этим согласны?

Если ответ на любой вопрос да, то тогда можно нарушить консистентность.


Тренды в разработке ПО


Наследование в ООП


Автор рекомендует с осторожностью использовать наследование реализации, поскольку это создает зависимость дочернего класса от родительского. Лучше стараться избегать её и по возможности использовать хелперы и композицию. Если все же использовали наследование, то постарайтесь сделать их менее зависимыми.


Agile


Автор отмечает, что создание дизайна кода также инкрементально как развитие проекта в Agile. Постоянно происходит улучшение и рефакторинг кода. Нет лучшего решения навсегда.
Проблема в том, что agile часто вынуждает прибегать к тактическому программированию, требуя фокусироваться на фичах, а не на абстракциях.


Unit тесты


В отличие от интеграционных тестов, за unit тесты отвечают разработчики. Интеграционные лучше отдавать отдельной QA команде.
Unit тесты хороши тем, что позволяют не бояться рефакторить код, если они имеют хорошее покрытие.


TDD


Проблема TDD в том, что она вынуждает концентрироваться на поиске способа реализации, а не нахождении лучшей абстракции.
Лучше всего он подходит на исправлении багов. Сначала мы пишем тест на баг, убеждаемся, что тест не проходит, а потом чиним его.


Паттерны


Основная их проблема в том, что их применяют тогда, когда лучше обойтись более простым решением.
Хороши они тем, что позволяют соблюсти консистентность.


Геттеры и сеттеры


Их лучше избегать, потому что они делают доступ, даже к приватным свойства, публичным. А публичные методы в любом случае усложняют интерфейс.


Заключение от меня


Я привел краткие заметки только по тем темам, которые показались важными для меня. В книге автор разбирает каждую тему очень подробно и приводит множество примеров.
Кроме того, в книге очень много красных флагов. Это такие ситуации, заметив которые, вы должны насторожиться и задуматься над дизайном. Это делает книгу очень полезной для практического применения во время работы.


Если вы читали эту книгу, напишите в комментарии, что для вас было самым полезным, если я упустил это в статье. Если вы поняли некоторые темы по другому или с чем-то не согласны, я буду рад это обсудить.

Подробнее..

Методы без аргументов зло для неизменяемых объектов, и вот как его полечить

23.11.2020 18:14:08 | Автор: admin

Привет!


Идея в том, что бы использовать ленивые кешируемые свойства везде, где в обычном случае мы бы использовали процессорно тяжелые методы без аргументов. А статья как это задизайнить и и зачем.


Несмотря на то, что я должен сделать оговорку,


Дисклеймер

Этот подход не подойдет в случаях:
1) Если вы пишете что-нибудь сверхбыстрое, и красивый код последнее, о чем думаете
2) Если ваши объекты никогда не используются дважды (например, беспрекословно соблюдается SRP)
3) Если вы настолько ненавидете свойства, что код их содержащий в ваших глазах покрывается блюром


мне очень нравится подход, которым я хочу поделиться в этой заметке, и я считаю, что такой дизайн удобен как авторам кода, так и его пользователям.


TL;DR в самом низу.


Почему зло?


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


public sealed record Integer(int Value);

У него есть одно свойство Value типа int. Теперь, нам понадобился следующий метод:


public sealed record Integer(int Value){    public Integer Triple() => new Integer(Value * 3);}

Каждый раз при необходимости утроить инстанс нашего числа, придется вызывать этот метод, и брать на себя ответственность за кеширование. Например, придется писать


public int SomeMethod(Integer number){    var tripled = number.Triple();    if (tripled.Value > 5)        return tripled.Value;    else        return 1;}

Вместо того, что бы писать


public int SomeMethod(Integer number)    => number.Tripled > 5 ? number.Tripled.Value : 1;

Красивее, короче, читабельнее, безопаснее. Потенциально, оно также быстрее, если у нас к одному и тому же Tripled происходит обращение не только здесь.


Что нам хочется?


  1. Удобный дизайн кода для его пользователя. Например, я не хочу думать о кешировании при обращении к объекту, я просто хочу от него данные.
  2. Бесплатность обращения к свойству. Время я плачу только за первое обращение, и это никогда не хуже, чем вызов метода (обычно почти как обращение к полю по стоимости).
  3. Удобный дизайн кода для его разработчика. Разрабатывая новый immutable object, я не хочу оверрайдить конструктор, Equals и GetHashCode рекорда просто потому, что я добавил какое-то приватное поле для кеша, которое внезапно ломает мне все сравнения.

Я уже привел пример того, насколько удобнее свойства чем методы, в очень простом случае. А как разработчик объекта, я хочу писать так:


public sealed record Number(int Value){    public int Number Tripled => tripled.GetValue(@this => new Number(@this.Value * 3), @this);    private FieldCache<Number> tripled;}

А вот в жааве

Можно было лучше, и насколько мне известно, в джаве это решается аттрибутом Cacheable. В шарпе недавно добавленные source-генераторы код изменять не могут, а значит такой же красоты мы по-любому не получим. Поэтому этот сэмпл лучшее, к чему я смог прийти.


А вот как пишут обычно:


Подход 1 (да зачем нам кеш?):


public sealed record Number(int Value){    public int Number Tripled => new Number(@this.Value * 3);}

(очень дорогой по очевидным причинам)


Подход 2 (используем Lazy<T>):


public sealed record Number : IEquatable<Number>{    public int Value { get; init; }  // приходится оверлоадить конструктор, поэтому выносим сюда    public int Number Tripled => tripled.Value;    private Lazy<Number> tripled;    public Number(int value)    {        Value = value;        tripled = new(() => value * 3);  // мы не можем это сделать в конструкторе поля, потому что на тот момент this-а еще не существует    }    // потому что Equals, который генерируется для рекордов, генерируется на основе полей, и поэтому наш Lazy<T> все сломает    public bool Equals(Number number) => Value == number.Value;    // то же самое с GetHashCode    public override int GetHashCode() => Value.GetHashCode();}

Как мы видим, очень сложно и неадекватно становится дизайнить наш объект. А что если там не одно кешируемое свойство, а несколько? За всем придется следить, включая все оверрайды.


Более того, у нас перестанет работать with, который клонирует все ваши поля, кроме указанного(-ых). Ведь он скопирует и ваше поле с Lazy, в котором будет лежать уже неверный кеш.


Подход 3 (используем ConditionalWeakTable):


public sealed record Number{    public Number Tripled => tripled.GetValue(this, @this => new Integer(@this.Value * 3));    private static ConditionalWeakTable<Number, Number> tripled = new();}

Наиболее адекватное решение среди прочих. Но для него придется писать обертку над ValueType так как ConditionalWeakTable принимает только референс-тип. Поэтому такая штука существенно медленнее, чем что-то подобное без оверхеда (по моему бенчмарку получается разница в, по меньшей мере, 6 раз, по сравнению с типом, о котором я расскажу).


Подход 4 (сразу посчитать):


public sealed record Number{    public int Value { get; init; }    public Number Tripled { get; }    public Number(int value)    {        Value = value;        Tripled = new Number { Value = value * 3 };    }}

В конкретно этом случае это вообще даст stackoverflow, но даже если нам повезло, и кешируемый тип не совпадает с "холдером" нам гарантированно придется заплатить временем за эту инициализацию, которая нам может и не понадобиться.


Решение


  1. Итак, начнем с того, что наш ленивый контейнер будет структурой. Зачем лишний раз кучить мучу мучить кучу?
  2. Equals и GetHashCode всегда будут возвращать true и 0 соответственно. Это убивает смысл этих методов, но этот контейнер нам нужен только ради кеша, а значит сам по себе не должен влиять на результаты сравнения двух рекордов или получения хеша. Таким образом, мы не обязаны оверрайдить Equals и GetHashCode для каждого рекорда, пусть об этом думает Рослин.
  3. Допустим любой тип в качестве кешируемого. Лочить будем по холдеру, то есть тому, в ком объявлен наш кеш.
  4. Фабрика передается не в конструкторе, а в методе GetValue, по тому же принципу, как у ConditionalWeakTable. Тогда не придется создавать конструктор и писать спаггети-код, как мы это делали с Lazy<T>.
  5. Чтобы не сломать замечательную операцию with, вместо переменной initialized мы будем сравнивать holder, и в случае изменения референса запускаем фабрику снова.

Коду!


Для начала, так у нас выглядят поля и оверрайденные методы:


public struct FieldCache<T> : IEquatable<FieldCache<T>>{    private T value;    private object holder; // от этой штуки нам нужен ТОЛЬКО референс, смысла делать его generic нет    // как я уже говорил, сделано, чтобы рослиновский Equals не сломался от приватного поля    public bool Equals(FieldCache<T> _) => true;    public override int GetHashCode() => 0;}

И примитивная имплементация GetValue выглядит так:


public struct FieldCache<T> : IEquatable<FieldCache<T>>{        public T GetValue<TThis>(Func<TThis, T> factory, TThis @this) where TThis : class // record - это тоже класс. А ограничение нужно, чтобы тип был референсным        {            // если холдер изменился ИЛИ еще не записывался (например, если он - null)            if (!ReferenceEquals(@this, holder))                lock (@this)                {                    if (!ReferenceEquals(@this, holder))                    {                        // мы передаем в фабрику, потому что наш FieldCache нужен для случаев, когда какие-то кешируемые проперти зависят ТОЛЬКО от полей нашего самого холдера. Можно, конечно, и захватить в передаваемой лямбде, но тогда будет реаллокация каждый раз                        value = factory(@this);                        holder = @this;                    }                }            return value;        }}

Таким образом, мы можем себе позволить такой дизайн:


public sealed record Number(int Value){    public int Number Tripled => tripled.GetValue(@this => new Number(@this.Value * 3), @this);    private FieldCache<Number> tripled;}

Код очень короткий, и его можно найти на гитхабе.


Производительность


Единственное, что быстрее, чем наш наивный FieldCache это встроенный Lazy<T>.


Method Mean
BenchFunction 4,599.1638 ns
Lazy 0.6717 ns
FieldCache 3.6674 ns
ConditionalWeakTable 25.0521 ns

BenchFunction это какие-то сложные страшные вычисления, которые производились бы каждый раз при обращении к методу, поэтому мы хотим его кешировать. Другие три строчки занимают три разных подхода. Как видим, FieldCache<T> немного помедленнее, чем Lazy<T>.


Я считаю, что так как он все равно занимает не очень много времени, во многих местах адекватный дизайн будет лучше, чем пару сэкономленных наносекунд.


Кратый TL;DR или выводы


Хотелка: ленивые кешируемые свойства неизменяемых объектов, зависящие от первичных свойств данных объектов
И известные существующие подходы, по всей видимости, не дают это красиво сделать, поэтому приходится писать свое.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru