Доброй ночи всем!
Давайте обсудим, какие есть запахи кода и антипаттерны в Рефале. Но сначала приведу определения, т.к. с терминами в рассылке могут быть знакомы не все. Если термины знаете, можете промотать кусок письма.
Определения
Есть два близких, но разных понятия: запахи кода и антипаттерны.
Запах кода — эвристический признак того, что в программе есть какие-то проблемы. Например, какие-то участки программы сложны для восприятия, или более подвержены ошибкам. Борьба с запахом кода — рефакторинг, т.е. улучшение внутренней структуры программы без изменения видимого поведения.
Примеры запахов в ООП: дублирование кода, длинный класс, длинный метод, длинные списки параметров, параллельные иерархии наследования, мёртвый код…
Подробнее:
https://ru.wikipedia.org/wiki/Код_с_запашком
https://refactoring.guru/ru/refactoring/smells
Код с запахом плох тем, что в нём повышается вероятность ошибок. Если класс слишком большой или метод слишком длинный, то понять его сложнее, а значит, проще допустить ошибку. Если в программе код повторяется, то при внесении изменений в него нужно согласованно поправить все копии, но по ошибке некоторые можно пропустить.
Антипаттерн — это распространённый подход к решению класса часто встречающихся проблем, являющийся неэффективным, рискованным или непродуктивным (определение из Википедии). Т.е. распространённая, но плохая «идиома» программирования.
Примеры антипаттернов: программирование методом копипаста, магические константы, спагетти-код (слишком запутанная логика), лазанья-код (слишком много слоёв абстракции), зависимости по побочному эффекту, жёсткое кодирование (hard code) — зашивание в код того, что должно быть вынесено в конфигурацию, мягкое кодирование (soft code) — в конфигурацию вынесено слишком много, конфигурирование само по себе превращается в программирование.
Подробнее:
https://ru.wikipedia.org/wiki/Антипаттерн
В отличие от запаха кода, который при эволюции программы часто возникает сам собой (и подлежит немедленному рефакторингу), антипаттерны программисты пишут явно и намеренно. Пишут, зная или не зная, что так писать не надо.
Запахи и антипаттерны в Рефале
Из запахов кода в Рефале я могу перечислить только платформенно-независимые. Т.е. взяв уже упомянутый список с refactoring.guru и вычеркнув из него вещи, характерные для ООП:
· Длинная функция. Менее актуально для Рефала-2, более актуально для других диалектов. В Рефале-2 функция может быть длинной или из-за обилия предложений, или из-за длины самих выражений в левой и правой части. В других диалектах Рефала функция может расти в длину из-за обилия управляющих конструкций, таких как условия, блоки, присваивания, действия… В Рефале-5λ вообще можно писать вложенные функции, например, внутри Map, и их тоже можно написать длинными.
· Большой класс. В Рефале нет классов. Но можно сказать, что если файл с исходным текстом длинный, значит в нём намешано много всего и его, возможно, стоит разделить на несколько с разными подмножествами функций.
· Длинный список параметров. Если в формате функции много полей, значит, она сложна или имеется невыявленная абстракция (см. следующий пункт).
· Группы данных. Можно передавать какие-то значения в виде отдельных кусков, а не сгруппировав их в некоторую абстракцию. Например, компилятор отслеживает номер строки, номер символа в строке и имя файла для указания точного места в сообщениях об ошибке. Можно их передавать как s.Line s.Col e.FileName, а можно передавать как t.Pos, внутри которого лежат искомые значения.
· Расходящиеся модификации, стрельба дробью — при внесении изменений приходится делать правки в разных местах программы.
· Комментарии. Имеются ввиду комментарии, которые объясняют, как работает сложный участок кода. Нужно переписать код так, чтобы он стал понятен без комментариев — дать переменным и функциям более понятные имена, разнести логику по отдельным функциям с ясными именами и т.д.
· Дублирование кода — тут всё понятно.
· Мёртвый код — в результате развития в программе появился код, который никогда не выполняется. Его надо удалить.
· Теоретическая общность. Задача была решена с запасом на будущее, хотя решение для частного случая было бы проще и компактнее. Время показало, что этот запас оказался избыточен.
Остальные пункты относятся только к ООП.
Каких-то запахов кода, характерных именно для Рефала, я назвать не могу.
А вот антипаттерны могу. Приводить список кросс-платформенных антипаттернов — антипаттернов, применимых к любому языку программирования, не буду, чтобы не загромождать письмо. По ссылке
https://ru.wikipedia.org/wiki/Антипаттерн#Антипаттерны_в_кодировании
к Рефалу применима где-то половина списка. Поэтому приведу антипаттерны, характерные для Рефала:
· Функция с несколькими форматами. Если образцы разных предложений функции соответствуют разным форматам, но документирован только один из них, значит программист попытался слить с основной функцией одну или несколько вспомогательных.
· Слишком общий образец. Образец одного или нескольких предложений более общий, чем документированный формат функции. Проблема в том, что такой образец может скрыть ошибку в программе. Функция может где-то вызываться с неправильным форматом аргумента. И если бы все образцы были бы уточнением формата, функция упала бы, выявив ошибку. Но более общий образец (в предельном случае даже e.X) может съесть неправильный аргумент и программа вместо падения может падать где-то в другом месте или вычислять неправильный результат. Удачи в отладке!
· «Нумерование» вспомогательных функций. Т.е. имена вспомогательных функций для функции FuncName будут иметь вид FuncName1, FuncName3, FuncName4… Чтобы понять смысл грамотно поименованной функции, достаточно прочитать её имя. Чтобы понять смысл пронумерованной функции, нужно целиком прочитать её тело, т.к. смысл не всегда понятен по контексту вызова. Вообще, в развитых диалектах Рефала надобности во вспомогательных функциях гораздо меньше, чем в базисном Рефале (например, Рефале-2).
· Использование чисел или литер вместо символов-слов. Например, функция может принимать одним из параметров или число 1, которое обозначает истину, или 0 (ложь). Правильнее вместо них использовать слова True и False. А ещё правильнее — вместо True/False по возможности использовать слова из предметной области: Found/NotFound, Simple/Complex, Changed/Intouch, Success/Errors…
А какие вы можете назвать запахи кода и антипаттерны, характерные для Рефала?
С уважением,
Александр Коновалов
Доброе утро всем!
> 1. Выворачивание скобок наизнанку, для организации прохода по выражению.
К представлению объектных выражений этот приём (антиприём) не имеет отношение.
Он, скорее, характерен для базисного Рефала (Рефала-2), в котором нет ничего подобного let-конструкциям (условия, перестройки, действия).
У меня в Рефале-5λ копирование выражений дорогое, но я копировать их по умолчанию не боюсь. Потому что потом я нахожу узкие места, изучая профиль программы, и устраняю лишние копирования уже в них.
А нет ли в Рефале Плюс другой боязни — боязни конкатенации?
Enum {
e.Expr
= <DoEnum e.Expr 1> : e.Expr^ s.Num
= e.Expr;
}
Добрый день, Андрей!
«Не согласен! Ещё как имеет. Нельзя не думать об эффективности, выбирая шаблоны программирования, особенно когда речь не о коэффициенте, а об уменьшении сложности, скажем с квадратичной до линейной.»
А где возникает квадратичная сложность при сквозном обходе выражений (т.е. выворачивании скобок)? Или где она возникает в других шаблонах решения данной задачи (обход выражения в глубину)? На каких представлениях?
«Заниматься переписыванием читабельной программы на нечитабельную — это, по-моему, как раз пример „антипаттерна“»
Согласен. Но если руководствоваться профилировщиком, корячить приходится не всю программу, а только условно 3 %.
«IMHO, это есть пример антипаттерна: повторное использование имени для нового значения. Я бы здесь обязательно написал е.Expr1. А то легко не понять при чтении и ошибиться при рефакторинге.»
Я занимаю прямо противоположную позицию. Когда мы используем знак ^, мы говорим, что подменяем старую сущность новой, обновлённой. А городить кучу разных пронумерованных переменных для семантически одного и того же — это на мой взгляд костыль.
Если пронумерованных переменных несколько, то можно в них запутаться и ошибиться при сопровождении программы.
Допустим, у нас есть некоторая сущность, которую нужно как-то обновить два раза и использовать:
F {
… e.Entity …
= <FirstUpdate e.Entity> : e.Entity^
= <SecondUpdate e.Entity> : e.Entity^
= <UseEntity … e.Entity …>
}
Допустим, нам теперь надо упростить структуру после обновлений. Добавляем одну строчку:
F {
… e.Entity …
= <FirstUpdate e.Entity> : e.Entity^
= <SecondUpdate e.Entity> : e.Entity^
= <Simplify e.Entity> : e.Entity^
= <UseEntity … e.Entity …>
}
А теперь нам нужно между обновлениями привести её к канонической форме:
F {
… e.Entity …
= <FirstUpdate e.Entity> : e.Entity^
= <Canonize e.Entity> : e.Entity^
= <SecondUpdate e.Entity> : e.Entity^
= <Simplify e.Entity> : e.Entity^
= <UseEntity … e.Entity …>
}
А с нумерацией возни будет больше. Исходный вариант:
F {
… e.Entity …
= <FirstUpdate e.Entity> : e.Entity1
= <SecondUpdate e.Entity1> : e.Entity2
= <UseEntity … e.Entity2 …>
}
Теперь добавляем упрощение:
F {
… e.Entity …
= <FirstUpdate e.Entity> : e.Entity1
= <SecondUpdate e.Entity1> : e.Entity2
= <Simplify e.Entity2> : e.Entity3
= <UseEntity … e.Entity3 …>
}
Нужно не только вставить строчку, но и перенумеровать переменную в последней строке. А теперь канонизация:
F {
… e.Entity …
= <FirstUpdate e.Entity> : e.Entity1
= <Canonize e.Entity1> : e.Entity4
= <SecondUpdate e.Entity4> : e.Entity2
= <Simplify e.Entity2> : e.Entity3
= <UseEntity … e.Entity3 …>
}
Нумерация у меня получилась не по порядку. Но можно придумать и какой-то другой номер, например, e.Entity15, типа полтора, или e.Entity1a. Какие-нибудь зануды могут перенумеровать все вхождения переменной.
Но проблема не только в выборе номера — проблема в том, что нужно не забыть перенумеровать вхождение переменной и в следующей строке тоже.
Зачем нумеровать вручную, когда это может сделать сам компилятор?
Эта цепочка присваиваний компилятором переписывается во вспомогательные функции. Если бы программист вручную писал вспомогательные функции, то никаких номеров бы не было:
F {
… e.Entity … = <F-FirstUpdated … <FirstUpdate e.Entity>>;
}
F-FirstUpdated {
… e.Entity = <F-Canonized … <Canonize e.Entity>>;
}
F-Canonized {
… e.Entity = <F-SecondUpdated … <SecondUpdate e.Entity>>;
}
F-SecondUpdated {
… e.Entity = <F-Simplified … <Simplify e.Entity>>;
}
F-Simplified {
… e.Entity = <UseEntity … e.Entity …>;
}
Поэтому номера — отстой!
Если уж не хочется переопределять имена, или язык не позволяет, то лучше давать понятные имена разным версиям:
F {
… e.Entity …
= <FirstUpdate e.Entity> : e.Entity-FirstUpdated
= <Canonize e.Entity-FirstUpdated> : e.Entity-Canonized
= <SecondUpdate e.Entity-Canonized> : e.Entity-SecondUpdated
= <Simplify e.Entity-SecondUpdated> : e.Entity-Simplified
= <UseEntity … e.Entity-Simplified …>
}
К слову, в Рефале Плюс переменные в перестройке совершенно спокойно перекрывают имена. Можно сказать, крышка ^ там подразумевается неявно.
««А нет ли в Рефале Плюс другой боязни — боязни конкатенации?»»
«Нет.»
Ну а как тогда Enum будет записан на Рефале Плюс? Если его дословно переписать на Рефале Плюс, не будет ли там квадратичной сложности?
Хвостовой рекурсии там уже точно не будет, глубина вложенности будет пропорциональна не вложенности скобок, а длине выражения.
«В общие правила, что хорошо, а что плохо, не верю. Надо руководствоваться целевым критерием — изменяемостью, сопровождаемость программы. Хорошая программа та, которую легко менять, не ошибаясь. И всё.»
Ну, вообще есть общие эвристики, как нужно писать программы, чтобы их можно было менять, не ошибаясь. Т.е. следование которым в большинстве случаев приводит к написанию сопровождаемых программ с небольшим количеством ошибок. Если у функции очень много параметров, значит, она слишком сложна и её стоит как-то упростить, к примеру.
Но, как я описал в первом письме, эти правила в целом не зависят от языка (длинные функции, сложный код, требующий комментариев и т.д.). Интересно выделить специфичные для Рефала эвристики.
С уважением,
Александр Коновалов
From: Andrei Klimov andrei_AT_klimov.net <re...@botik.ru>
Sent: Monday, December 14, 2020 11:50 AM
To: re...@botik.ru
Subject: Re: Запахи кода и антипаттерны в Рефале
пн, 14 дек. 2020 г., 10:46 Александр Коновалов a.v.konovalov87_AT_mail.ru <re...@botik.ru>:
Добрый день, Василий!
А если мы пишем для многопоточного Рефала, то статический ящик или копилка не подойдёт. Нужно использовать или динамический ящик, или передавать номер через параметр. Получится как-то так:
Enum e1 = k/DoEnum/e1 /0/.
DoEnum s1 e2 sN = sN k/DoEnum/ e2 k/P1/sN..
(e1)e2 sN = k/DoEnum-Wrap/ k/DoEnum/ e1 sN. (e2).
DoEnum-Wrap e1 sN (e2) = (e1) k/DoEnum/ e2 sN.
Если что, я не запускал этот код, могут быть опечатки. Также это решение эффективнее по числу шагов, чем вариант с копилкой.
С уважением,
Александр Коновалов
Василий!
Выворачивание скобок, оно же сквозной просмотр, — это другое. Это использование стека для представления незакрытых скобок. Например, так:
Enum e1 = k/DoEnum/ /0/ ('$') e1 '$'.
* Формат: k/DoEnum/ sN (tL eS) eU tR
* eS — просканированная часть
* eU — непросканированная часть
* tL — стек просканированных частей слева
* tR — стек непросканированных частей справа
* символ просто переносим
DoEnum sN (tL eS) s1 eU tR = k/DoEnum/ k/P1/sN. (tL eS sN) eU tR.
* в скобочный терм спускаемся
sN (tL eS) (e1) eU tR = k/DoEnum/ sN ((tL eS)) e1 (eU tR).
* термы кончились, но стек не пустой
sN ((tL eS) e1) (eU tR) = k/DoEnum/ sN (tL eS (e1)) eU tR.
* термы кончились и стек тоже пустой — выход из цикла
sN ('$' eS) '$' = eS
Почему здесь скобки наизнанку? Потому что для частично просмотренного выражения вида
e1 (e2 (e3 ^ e4) e5) e6
где знак ^ означает позицию, вызов функции DoEnum будет иметь вид
k/DoEnum/ ((('$' e1) e2) e3) e4 (e5 (e6 '$')).
Т.е. незакрытые скобки перед ^ оказываются закрывающими, а неоткрытые после ^ — открывающими. А скобка между e3 и e4 служит указателем ^.
У Турчина в учебнике по Рефалу-5 предлагается немного другой способ кодирования стека, но он не менее страшный, чем этот. Сам Турчин, похоже, осознавал неестественность такого программирования, поэтому в комплекте с Рефалом-5 поставляется препроцессор для сквозного просмотра скобок.
Как по мне, лучше использовать рекурсию для обхода рекурсивных структур, чем наворачивать такие стеки.
Василий!
Разница тут принципиальная. В первом варианте стек был системный, программист писал рекурсивную функцию, явно со стеком не манипулировал. Поскольку программист за стек не отвечает, сломаться стек не может. Во втором случае — стеком явно управляет программист. И он может ошибиться, например, положить состояние на один стек и не положить на другой. Или снять состояние только с одного стека. В любом случае стеки рассинхронизируются. Или допустить ошибку в формате, перепутав стек с обрабатываемыми данными (например, перепутав tL и tR).
a =/0/k/Enum/ 'abc' (('de') 'f' ('gh') 'ij') 'k'.
Enum e1 = k/EndEnum/k/DoEnum/e1 /1/..
DoEnum s1 e2 sN = sN k/DoEnum/ e2 k/P1/sN..
(e1)e2 sN = k/DoEnum-Wrap/ (k/DoEnum/ e1 sN.) e2.
sN = sN
DoEnum-Wrap (e1 sN) e2 = (e1) k/DoEnum/ e2 sN.
EndEnum e1 sN = e1
И нету оборачивания скобки в рекурсивном решении.
Один шаг сэкономился на EndEnum, т.к. в последнем предложении мы и стеки отбрасываем, и счётчик. Ещё шаги экономятся на обработке скобок. Открывающая скобка в обоих вариантах требует одного шага. Закрывающая скобка — двух шагов: последнее предложение DoEnum, где она видит аргумент с пустой строкой и счётчиком и DoEnum-Wrap, которая выбрасывает вон скобку и продолжает цикл DoEnum. Но закрывающих скобок 4, должно сэкономиться 4 шага. И один на EndEnum. 4+1≠4, где-то что-то я не учёл.
Василий, не надо пошаговую прокрутку. Я неправильно посчитал скобки. Во входной строке их не 4, а 3, поэтому всё сходится: 3+1=4.
Я вспоминаю, что Турчин был очень доволен тем, что нашел алгоритм СПАРИВАНИЯ СКОБОК, работающий линейное время, а сквозной просмотр появился как вариация на ту же тему.
Мне кажется, что сам сквозной просмотр он не слишком пропагандировал.
Доброй ночи, Аркадий!
Спасибо за пояснение про намерения Турчина! Это действительно интересно.
На самом деле, решение
Enum e1 = k/EndEnum/k/DoEnum/e1 /1/..
DoEnum s1 e2 sN = sN k/DoEnum/ e2 k/P1/sN..
(e1)e2 sN = k/DoEnum-Wrap/ (k/DoEnum/ e1 sN.) e2.
sN = sN
DoEnum-Wrap (e1 sN) e2 = (e1) k/DoEnum/ e2 sN.
EndEnum e1 sN = e1
тоже в рекурсивном стиле. Ведь при этом накапливаются вложенные конкретизации в поле зрения. А выворачивание — итеративное, на каждом шаге в поле зрения конкретизация одна.
С уважением,
Александр Коновалов
Доброй ночи, Николай!
Сквозной просмотр был у Турчина в учебнике по Рефалу-5:
Глава 3. Основные приемы программирования (koi8-r) (refal.ru)
Да, и одним из примеров является спаривание скобок.
Ещё этот приём используется в суперкомпиляторах SCP3 и SCP4. Можно исходники скачать и убедиться.
С уважением,
Александр Коновалов