Организация данных в удобные для использования структуры — одна из важнейших основ программирования. Организованная, именованная структура данных помогает нам писать более чистый и эффективный код, который можно использовать совместно с другими.
Однако я также считаю, что структуры уродливы и требуют слишком больших умственных затрат. Запоминание способа инициализации каждой структуры — это мучение, и чаще всего мы предпочитаем использовать умные конструкторы для выполнения нужной нам логики.
Или мы можем использовать самоизменяющиеся методы для создания структуры с течением времени, что является результатом цепочки методов. В моих глазах это гораздо лучший метод, потому что вам не нужно заполнять все детали структуры в точке ее инициирования.
Как основной пользователь Racket, я хочу рассказать о структурах в других языках и сравнить их с Racket, а также поделиться кроличьей норой инициализации структур и тем, как ее улучшить.
Структуры в Rust
Структура в Rust ничем не отличается от структуры в C или C++, или даже D, на самом деле. Структура — это список полей, содержащих некоторые данные, и мы применяем имена, к которым хотим привязать поля, чтобы в дальнейшем к ним было легко обращаться. По сути, это присоединение именованных полей к массиву данных, но этот массив данных не обязательно должен быть одного типа.
struct MyData {
pub a: i16,
pub b: u32,
pub c: f32,
}
Чтобы использовать эту функцию, мы должны определить привязку переменной, создать структуру и инициализировать все ее поля.
let my_point = MyData { a: 5, b: 77, c: 3.5 };
Но если в struct будет гораздо больше полей, это начнет выполняться довольно долго.
let my_long_struct = MyLongExample {
a: 0, b: 1, c: 2,
d: 3, e: 4, f: 5,
g: 6, // ...
};
Именно в этот момент вы захотите рассмотреть метод умного конструктора, который будет заполнять поля, которые вам не нужны немедленно, например, вы хотите, чтобы заполнялись только одно или два поля, а остальные по умолчанию переводились в нулевое состояние (числа обнулялись, булевы были ложными, строки пустыми и т.д.).
Умный конструктор имеет множество применений, но заполнение ненужных значений — это одно, а другое может быть удовлетворением правила инвариантности в более крупной структуре данных. Иногда при инициализации структуры необходимо выполнить некоторую логику, поэтому лучше всего остановиться на методе конструктора, который будет прост в использовании для всех.
impl MyData {
pub fn new(a: i16, b: u32) -> MyData {
return MyData { a: a, b: b, c: 0.0, };
}
}
Хотя это и улучшение, длинные вызовы метода конструктора все еще могут стать головной болью, и через некоторое время вы можете решить, что лучше создать более ориентированный на цепочку методов способ создания структур. Вместо того чтобы запихивать все данные в один вызов функции, вы можете создать несколько методов, которые упакуют и сохранят данные для вас, чтобы они меньше бросались в глаза.
impl MyData {
pub fn new(a: i16, b: u32) -> MyData { ... }
pub fn empty() -> MyData {
return MyData{ a: 0, b: 0, c: 0 };
}
pub fn set_a(mut self: MyData, a: i16) -> MyData {
self.a = a;
return self;
}
pub fn set_b(mut self: MyData, b: u32) -> MyData {
self.b = b;
return self;
}
}
Этот метод можно использовать следующим образом на месте инициализации:
let mystruct = MyData::empty()
.set_a(5)
.set_b(7);
Теперь более ясно, что вы устанавливаете, и логика может быть еще больше ограничена или лучше определена в этих методах сеттера. Таким образом, пользователи не смогут в дальнейшем нарушить какие-либо правила с вашими структурами, особенно если значения должны находиться в определенных диапазонах.
Ограничением использования переменных self
внутри реализации struct является то, что значение self
всегда должно ссылаться на сам тип struct, поэтому добавление дополнительной абстрактной типизации, такой как Option<T>
или Result<T,E>
в реализацию является обременительным. Даже если бы вы это сделали, сложно распаковать все уровни согласования шаблонов, которые это может включать, не находясь внутри еще одной функции Result<T, E>
для инкапсуляции оператора ?
.
Я большой поклонник цепочки методов для инициализации структур, так как это дает хороший компромисс между удовлетворением инвариантов программы и менее неуклюжим созданием структур из вашей крутой библиотеки исходного кода.
Теперь я рассмотрю Racket и покажу вам, почему я считаю его неуклюжим в его текущем состоянии.
Структуры в Racket
Racket — это язык семейства Lisp/Scheme, который позволяет вам делать гораздо больше, чем большинство других языков. Он может быть функциональным, объектно-ориентированным, его можно использовать для разработки приложений с графическим интерфейсом, игр или даже backend-серверов для обслуживания веб-страниц и другого контента. Racket, на мой взгляд, очень крутой язык, и я люблю говорить о нем.
Однако одна вещь, которая беспокоила меня долгое время, — это то, насколько скучны структуры. Структуры вроде как похожи на структуры в C/C++, D, Zig или Rust, но не совсем. Поскольку все в Racket является типом связанного списка, структура — это способ определения группы функций для создания этого связанного списка и автоматического создания функций, необходимых для перехода по этому списку.
(struct Point (x y z) #:transparent)
(define p1 (Point 3 4 5))
(displayln p1)
; shows #(struct:Point 3 4 5)
Магия макроса struct
заключается в том, что он генерирует кучу шаблонных методов вместе с вашим макетом.
Если посмотреть на вышеприведенный пример, то мы получим следующие значения:
Поскольку базовые данные struct
представляют собой связный список (или, черт возьми, это может быть не связный список, а хэш-карта), создание методов getter для получения полей является тривиальной макрореализацией. То же самое, что и функция Point?
— все это тривиальные макросы.
Мои проблемы с этой общей макрореализацией struct
в том, что она страдает тем же, что и другие проблемы языка struct
: как только полей становится слишком много, инициализация структур становится более утомительной, чем что-либо другое, поскольку вы должны помнить точный порядок нужных вам значений, что делает ее довольно сложной. Тем более сложно в Lisp-подобном языке.
(struct VeryLong (a b c d e f g ...))
(define my-data
(VeryLong 1 2 3 4 5 6 7 ...))
В этом примере, поскольку при создании структуры не требуется указывать имена полей, становится трудновато запомнить, что именно делает каждое поле. Даже если вы назвали все поля, при создании структуры имена полей не требуются, что делает более неясным, что вы на самом деле создаете. Вам придется обратиться к исходному коду, чтобы понять, что это за поля, которые вы заполняете.
Тогда мы могли бы подумать о создании более умного конструктора, чтобы помочь нам еще больше ограничить инварианты программы, что было бы разумно. Вы даже можете использовать именованные поля с умным конструктором, чтобы можно было задавать значения по умолчанию и смешивать их по своему усмотрению.
(define (Builder #:value1 [v1 0]
#:value2 [v2 "hi"]
#:value3 [v3 '(1 2 3 4 5)])
(SomeStruct v1 v2 v3))
(define mydata (Builder #:value1 500 #:value3 '(4 5))
; #(struct:SomeStruct 500 "hi" (4 5))
Это разумное решение для определения лучшего интерфейса для вашей библиотеки, но код может показаться слишком длинным, поскольку список аргументов ключевых слов может становиться все длиннее, чем дальше продвигается ваша структура.
Давайте рассмотрим ранее использованный в Rust путь цепочки методов — допустим, нам дана некоторая структура, и мы хотим скопировать ее так, чтобы одно поле было изменено. Для этого существует макрос struct-copy
, который скопирует struct и, задав парный список идентификаторов и новых значений, изменит эти поля на заданные значения.
(struct Point (x y z) #:transparent)
(define p1 (Point 3 4 5))
(define p2
(struct-copy Point p1 [x 7] [z 9]))
(displayln p2)
; #(struct:Point 7 4 9)
struct-copy
— это макрос, который генерирует код для создания новой структуры из старой. Если бы мы делали это без struct-copy
, это выглядело бы немного… странно, я думаю.
(define p1 (Point 3 4 5))
; copy the original values without struct-copy
(define p2
(Point (Point-x p1)
(Point-y p1)
(Point-z p1))
; use the old struct, but supply new values
(define p3
(Point (Point-x p2) 8 (Point-z p2)))
struct-copy
— удобный макрос для этой ситуации, потому что копирование информации struct — скучное занятие.
Чтобы сделать возможным использование какого-то метода-цепочки, мы должны написать какую-то функцию, которая будет копировать старое состояние struct и копировать его в новое через struct-copy
. Это можно написать как простую функцию.
(define (change-x old-state new-value)
(struct-copy Point old-state [x new-value]))
Отлично, теперь мы можем использовать это.
(define p1 (Point 3 4 5))
(define p2 (change-x p1 700))
; #(struct:Point 700 4 5)
… Честно говоря, я не уверен, что это что-то изменило. Если бы мы продолжили писать больше методов для удовлетворения других полей:
(define (change-y old-state new-value)
(struct-copy Point old-state [y new-value]))
(define (change-z old-state new-value)
(struct-copy Point old-state [z new-value]))
Это все равно не очень полезно, потому что данные должны храниться где-то между каждым вызовом. Вам придется определять новую привязку каждый раз, когда вы хотите использовать эти функции, или вы должны соединить их все вместе в одну очень большую композицию.
(define p3
(change-z
(change-x
(change-y p2 700)
800)
900))
; #(struct:Point 800 700 900)
Какая неразбериха. Было бы лучше, если бы мы могли как-то скомпоновать это в более чистый способ модификации старых структур. Конечно, если бы мы поменяли местами новое значение и старую структуру, это выглядело бы по-другому:
(define p3
(change-z 900
(change-x 800
(change-y 700 p2))))
Тем не менее, возможно, есть лучший способ.
Складывание функций над состоянием
Наш способ составления функций struct-copy
не очень приятен в его нынешнем виде, но, возможно, есть способ изменить это. В этом разделе я представлю способ складывания функций над состоянием.
Далее будет представлен миниатюрный глоссарий:
- Функция — это часть кода, которая принимает значение и возвращает некоторый вывод.
- Состояние — это некоторое значение, представляющее состояние системы, т.е. число или набор чисел/значений.
- Складывание — это идея «складывания» функции над списком значений, уменьшая их до конечного значения (как складывание бумаги до тех пор, пока не останется возможности сложить ее дальше).
Здесь мы имеем пример функций и состояния: наши структуры — это наше состояние, и мы хотим создать несколько функций, которые могут копировать старое состояние и возвращать новое, обновленное состояние.
Учитывая функцию f
и некоторое состояние s
, мы можем «запустить» функцию, передав состояние функции. Мы можем определить это следующим образом:
(define (run-state f s)
(f s))
Это кажется излишним, но в скором времени это станет важным.
Следующая часть заключается в том, что нам нужно переписать наши пользовательские методы модификации структур. Вместо того чтобы писать функцию, которая принимает некоторое состояние и новое значение, давайте изменим ее на curried форму, где она принимает значение, затем выдает функцию, которая принимает некоторое состояние, а затем применяет struct-copy
.
(define (change-x new-value)
(lambda (old-state)
(struct-copy Point old-state [x new-value])))
Это дает нам не функцию, которая непосредственно манипулирует состоянием, а функцию, которая впоследствии будет манипулировать состоянием. Это немного отличается, но я думаю, что это здорово, так как использует преимущества керринга.
(define set-x-500 (change-x 500))
(define p1 (Point 3 4 5))
(define p2 (set-x-500 p1))
;#(struct:Point 500 4 5)
Мы можем сделать то же самое и для других полей.
(define (change-y new-value)
(lambda (old-state)
(struct-copy Point old-state [y new-value])))
(define (change-z new-value)
(lambda (old-state)
(struct-copy Point old-state [z new-value])))
Теперь у нас что-то получается. У нас есть косвенный метод создания функций-сеттеров для заданной структуры. Теперь нам нужен способ склеить все это вместе. В текущей форме это будет выглядеть следующим образом:
(define p1 (Point 3 4 5))
(define p2
((change-x 500)
((change-y 300) p1)))
Немного странно, но мы можем переписать это с помощью функции сложения.
Для примера, сложение — это способ сокращения списка значений путем предоставления некоторого начального значения в качестве стартового, и у нас есть функция «reducer», которая многократно применяется к значениям по мере прохождения по списку.
(foldl + 0 '(1 2 3 4 5))
; 15
; same as
; (+ 5 (+ 4 (+ 3 (+ 2 (+ 1 0)))))
Она начинается с вызова, который выглядит как (+ 0 1)
, что является первым значением списка с начальным значением, переданным функции fold (0
в данном случае). Принцип работы этой функции заключается в том, что она использует двухаргументную функцию для приема некоторого значения и применения функции с некоторым «накопленным» значением.
Эту функцию я буду называть «редуктором», поскольку она принимает все эти значения и пытается достичь некоторого конечного значения после серии вызовов.
(define (reducer value acc)
(+ value acc))
(foldl reducer 0 '(1 2 3 4 5))
; 15
Давайте немного переосмыслим это: должен ли список значений быть каким-то списком атомарных значений, таких как числа? Нет! Это может быть что угодно, потому что Racket — это динамический язык, в котором все значения рассматриваются как обычные граждане. Это значит, что список значений может быть и списком функций!
; from earlier
(define (run-state f s)
(f s))
; use the `add1` function 3 times
(foldl run-state 0 (list add1 add1 add1))
; 3
Поскольку мы изменили наши функции модификации структур в curried-форму, они возвращают функцию, которая изменяет некоторое состояние, что означает, что мы можем фактически переписать все наши функции-обертки struct-copy
в вызов функции foldl
.
(foldl run-state (Point 0 0 0)
(list
(change-x 500)
(change-y 600)
(change-z 700)))
; #(struct:Point 500 600 700)
Это почти похоже на метод-цепочку из Rust! Чтобы просто оставить некоторые поля нулевыми, можно убрать их из заданного списка полностью, а можно поставить столько, сколько нужно. Я бы не сказал, что это что-то революционное, но это определенно открывает двери для гораздо более чистого и точного кода, в который ваши пользователи (и вы) могут влюбиться.
Единственное, чего здесь не хватает, так это лучшего способа создания пустых структур, что мы сделали в разделе Rust, так что сейчас я восполню этот недостаток.
(define (init-point)
(Point 0 0 0))
(foldl run-state (init-point)
(list
(change-x 500)
(change-y 600)
(change-z 700)))
Теперь вам не нужно инициализировать структуру самостоятельно, и вы можете просто использовать простой инициализатор, чтобы установить ее в какую-нибудь структуру по умолчанию.
На данном этапе мы создали уникальный способ определения наших структур, и это может быть полезно при создании библиотек и ограничении данных нужными вам способами. Но есть еще одна вещь, на которой я хочу остановиться очень быстро: создание макроса.
Макросы
Макрос — это способ генерации кода в Racket и большинстве других языков. В то время как обычные функции работают с данными, макрос работает с фактическим кодом, который вы пишете. Макрос возвращает список кода, который затем должен быть оценен виртуальной машиной Racket.
Макросы упрощают программирование, а языки, поддерживающие написание макросов, восхитительны тем, что в них можно исключить все то, что вам не нравится писать, упростить все и создать свой собственный миниатюрный язык для решения конкретной проблемы.
Я не собираюсь усложнять, но прямо сейчас, если вы хотите определить структуру с помощью наших новых функций, не очень приятно вручную писать вызов foldl
самому, и не очень весело заставлять пользователей делать это каждый раз.
Вместо этого можно написать функцию, которая сделает это за вас.
(define (make-point . funs)
(foldl run-state (init-point) funs))
(define p1
(make-point
(change-x 500)
(change-y 600)))
; #(struct:Point 500 600 0)
Это лучше, и благодаря тому, что функции Racket могут работать с несколькими значениями, список функций привязывается к аргументу funs
, который легко передается в foldl
.
Тем не менее, даже если посмотреть на это, все равно получается довольно утомительно. Моя критика заключается в том, что он все еще слишком многословен. Вы знаете, что хотите создать точку, так почему бы просто не перейти сразу к делу? Вот здесь-то макрос и поможет нам сократить код.
Чтобы кратко рассказать о макросах, давайте напишем простой макрос, который удвоит значение, буквально «удвоив» его. Для определения макросов мы используем define-syntax-rule
.
(define-syntax-rule (double x)
'(x x))
; (double 5)
; '(5 5)
; (double "hello")
; '("hello" "hello")
; (double it)
; '(it it)
Racket берет это правило и выполняет преобразование языка путем подстановки. Значение x
в этом макросе заменяется на то, что мы передали макросу. В этот момент не происходит расширения переменных, потому что выполняется буквальный перевод. Когда мы подставляем it
, он не ищет привязки переменной для it
, а превращает ее в символ из-за буквального перевода.
При написании макросов все, что вы делаете, это возвращаете обычный код Racket. Этот код не будет оценен позже, поэтому мы можем использовать его для автоматической генерации кода для нас.
Давайте рассмотрим макрос, который обрабатывает множество значений и рассматривает их как список. Для этого потребуется специальное связывание имен, известное как многоточие, или ...
, которое дает нам способ привязки многих значений к одному связыванию в пространстве макросов. Мы можем перевести код в последовательность begin
, просто присоединив список кода к исходному макросу begin
.
(define-syntax-rule (my-begin code ...)
(begin code ...))
(my-begin
(displayln 1)
(displayln 2))
; 1
; 2
Не очень сложный, но он действует как способ перевода кода из одной формы в другую. На этом уровне макрос define-syntax-rule
является одним из более простых способов написания макросов, и он позволяет нам зайти так далеко, но этого достаточно для настоящего момента.
Моя цель — создать макрос, который будет выполнять вышеупомянутый код (define (make-point ...)
за нас, чтобы нам не приходилось всегда делать это таким образом. Это кажется излишним, поэтому, возможно, давайте напишем макрос, чтобы сделать это немного проще.
(define-syntax-rule (define-point name code ...)
(define name
(foldl run-state (init-point) (list code ...))))
Поэтому вместо того, чтобы делать:
(define p1
(make-point
(change-x 500)
(change-y 600)))
мы можем сделать следующее:
(define-point p1
(change-x 500)
(change-y 600))
Используя многоточие, мы можем преобразовать список синтаксиса в список кода, который будет использоваться функцией foldl
, и, предоставив ей некоторые основные значения, такие как run-state
и init-point
, она действует как хороший и чистый способ создания нашей структуры Point.
Это экономит несколько строк кода, но имя макроса дает понять, что вы на самом деле пытаетесь определить новую структуру Point, а указание имени переменной отсылает к исходному макросу define
, так что на самом деле это хороший способ пропустить несколько шагов и упростить выполнение того, что пытается сделать ваша библиотека.
Заключение
Структуры — это здорово, но чем сложнее становится структура, тем труднее поддерживать код, окружающий ее. Даже добавление одного поля в структуру может означать тонны изменений во всех исходных местах вызова.
Умные конструкторы и закрепление методов — это хорошие идеи для того, чтобы компенсировать количество утомительной работы, которую вам придется проделать, чтобы внести нужные изменения. Кроме того, цепочка методов — это хороший интерактивный способ построения данных.
В Racket эти подходы не заложены, но это не значит, что они невозможны. Магия макросов позволяет легко заполнить пробелы и создать более оптимизированный опыт для себя и своих пользователей. Я использую этот метод складывания функций уже некоторое время и решил поделиться им со всеми.
Спасибо за чтение!
(Изображение заголовка принадлежит @jannerboy62 на Unsplash)