Статии1 август 2026 г.11 мин четене
Метапрограмиране: Елегантно оръжие за по-цивилизовани времена

Особеното при светлинните мечове, и филмите някак подминават този факт, е, че всеки джедай трябва да си сглоби сам своя.
Няма интендант. Отиваш в пещера на някоя неприятна планета, намираш кристал, готов да те изтърпи, и сглобяваш устройство, което, ако си допуснал грешка, ще ти отнесе ръката до рамото.[1] Оръжието е елегантно, защото този, който го държи, го е направил и знае коя част е най-вероятно да се обърка.
През повечето време пишем програми. Понякога пишем програмата, която пише програмата, в който момент работата изцяло променя характера си. Инструментът престава да бъде нещо, връчено ти от комисия в друга държава, и се превръща в нещо, което си изваял сам, което е вълнуващо за около две седмици, а после се превръща в отговорност.
Най-старата идея в занаята
През 1936 г. Alan Turing описва машина, която по същество е изключително глупав чиновник. Той има безкрайно дълъг тефтер, няколко правила и абсолютно никакво любопитство. Чете символ, сверява с правилата, пише символ, мести се с едно квадратче настрани. Не му плащат добре и той не се оплаква.
Тогава Turing направил онова, което белязало остатъка от века. В тефтера можело да се напише описание на друг чиновник и първият, следвайки собствените си правила със същата липса на въображение, щял да се държи досущ като втория. Описанието е данни. Да си машина е това, което се случва, когато нещо прочете описанието.
Von Neumann пренесъл това в хардуера, където инструкциите и числата делят една и съща памет, подобно на село, чиято пожарна команда и отбор по дартс са едни и същи шестима мъже и на никого никога това не се е струвало объркващо.[2]
Вие сте компилирани от изходен код
Вашият геном съдържа инструкциите за изграждане на машината, която чете вашия геном. Нещо повече, лимфоцитът нарязва ДНК-то, с което се е сдобил при раждането си, и го сглобява наново в рецепторен ген, който никога не е съществувал у никой предшественик. През вратата е влязло нещо, за което никой няма формуляр, така че клетката написва нов формуляр от стария.
Прелюдиите се опитаха да обяснят Силата чрез биология и интернет се цупи за това от двадесет и пет години. Биологията е стигнала дотам няколко милиарда години по-рано и никой не е написал нито едно гневно писмо.
Макросът е функция с необичайно работно време
Макросът е функция. Това е по-голямата част от истината. Приема аргументи, има тяло, връща стойност. Две неща са различни и двете са свързани с времето.
Изпълнява се, докато програмата се изгражда, а не докато работи. И не получава стойността на аргументите си. Получава кода, който сте написали, предаден като структура от данни, все още в едно парче.
Да предположим, че напишете assert(total == expected).
Функция, наречена assert, е пратеник, на когото са казали отговора, но е забравил въпроса. Тя получава false. Имената са се изпарили, числата са се изпарили и най-многото, което може да ви каже, е, че нещо, някъде, не е било вярно. Всеки, който чете това, се е сблъсквал с това съобщение в два през нощта и е казал нещо нецензурно на екрана.
Макрос, наречен assert, получава целия разговор.
вие пишете: assert(total == expected)
функцията получава: false
макросът получава: (== (var total) (var expected))
Държейки дървото, той може да бръкне, да извади имената и да върне нов код, който извършва сравнението и при неуспех съобщава и двете имена, заедно с двете им стойности. Компилаторът поставя този код на мястото на вашето извикване и продължава, сякаш нищо не се е случило. Докато програмата се изпълни, в сградата вече няма и следа от макрос, а само кодът, който е оставил след себе си.
Това е и разликата между библиотека и езиково разширение. Библиотеката ви дава нови глаголи в рамките на граматика, която някой друг е закотвил преди години в заседателна зала. Макросът ви позволява да спорите с граматиката, защото той решава дали изобщо да изчисли аргумента си, което е власт, каквато обикновената функция няма и не може да ѝ бъде дадена назаем.
Това, което се различава между езиците, е как изглежда дървото, когато пристигне, и каква част от него можете да пипнете без разрешително.
Йода пише на Lisp
Lisp е на около седемдесет години, което в този бранш го прави геоложка формация, а критиците му през повечето от това време повтарят една и съща шега за скобите.[3]
Очевидната шега е словоредът. Интересното е, че в Lisp вашата програма е списък. Не се съхранява в списък, не се компилира в списък. Тя е списък, в паметта, в същата форма като данните ви, така че една програма може да разглоби друга, използвайки функциите, с които пазарува.
(defmacro aif (test then &optional else)
`(let ((it ,test))
(if it ,then ,else)))
(aif (find-user id)
(format nil "welcome, ~a" (name it))
"no such user")
Вижте какво стана с it. Макросът въведе име, което извикващият код никога не е декларирал, а извикващият код го използва, сякаш винаги е било там. Това е анафорично улавяне, то е умишлено и в повечето други езици биха ви изхвърлили от сградата заради него. Lisp го позволява, защото Lisp няма мнение за това какво би трябвало да искате.
На деветстотин години, висок две стъпки, сглобява изречението отзад напред и все още е най-опасното нещо в стаята.
Хан Соло управлява препроцесора на C
Препроцесорът не е лош човек. Той е много добър човек в работа, която никой не е трябвало да му дава.
Той се изпълнява, преди компилаторът да има и най-бегла представа какво се случва, замества текст с друг текст и е бърз, когато ситуацията се влошава. Напишете #define MAX(a,b) ((a)>(b)?(a):(b)), извикайте го като MAX(i++, j++) и една от променливите ви ще се инкрементира два пъти. Някой друг ще разбере за това, в неделя, в система за разплащания.[4]
И все пак, ето го как прави онова, в което е доста добър:
#define ERRORS \
X(OK, "fine") \
X(TIMEOUT, "took too long") \
X(REFUSED, "declined by upstream")
typedef enum {
#define X(code, msg) ERR_##code,
ERRORS
#undef X
} error_t;
static const char *error_text[] = {
#define X(code, msg) msg,
ERRORS
#undef X
};
Списъкът съществува само веднъж. enum-ът и таблицата със съобщения израстват от него и не могат да се раздалечат, защото няма какво да се раздалечава. Номерът е по-стар от повечето хора, които го използват, и не изисква нито стъпка за компилация, нито зависимост, нито среща.
Никога не е чувал за тип. Няма мнение за обхват. Ще ви отнесе крака, докато настоява, че е бил с предимство. Той е също така, от време на време, точният човек за работата, което е частта, която хората пропускат в надгробното слово.
Палпатин и шаблоните на C++
Никой не тръгва с идеята да изгради Империя. Империите са това, което получавате, когато поредица от напълно разумни предложения бъдат одобрени от хора, които мислят за обяда си.
Шаблоните се появили като скромно предложение относно генеричните контейнери. След това през 1994 г. Erwin Unruh написал програма, която изчислявала прости числа и ги предоставяла като съобщения за грешка от компилатора, демонстрирайки, че системата за шаблони е, случайно, завършен език за програмиране, който никой не е проектирал или гласувал.
template<int N> struct Fib {
static constexpr int value = Fib<N-1>::value + Fib<N-2>::value;
};
template<> struct Fib<1> { static constexpr int value = 1; };
template<> struct Fib<0> { static constexpr int value = 0; };
static_assert(Fib<20>::value == 6765);
Прочетете го отново с правилната доза ужас. Рекурсията е шаблонът, базовият случай е специализация, а аритметиката се случва вътре в компилатора, на език без учебник по граматика. Нито една машинна инструкция не се генерира за всичко това.
По това време армията от клонинги вече е била поръчана. Всяко инстанциране произвежда още една идентична единица, поискана от никого конкретно и начислена към бюджета ви за CI. Компилаторът се труди цяла нощ, а съобщенията за грешки пристигат на закуска, четиристотин реда от тях, търпеливо описващи тип, чието име не се побира на монитора.
Мейс Уинду борави с Rust
Rust погледнал предходните две глави от историята и направил това, което всяка разумна цивилизация прави след голям пожар. Написал строителен кодекс.
macro_rules! hashmap {
($($k:expr => $v:expr),* $(,)?) => {{
let mut m = ::std::collections::HashMap::new();
$( m.insert($k, $v); )*
m
}};
}
let config = hashmap!{ "retries" => 3, "timeout" => 30 };
Той съпоставя по синтаксис, а не по текст, а завършващото $(,)? прощава случайна запетая, защото Rust е строг, но не и безпричинно жесток. Важната част е невидима: онова m вътре в разширението не може да се сблъска с променлива, наречена m, във вашия код. Това, което Lisp позволяваше умишлено с it, Rust забранява по принцип.
Всичко по-амбициозно се превръща в процедурен макрос, което означава syn, quote, собствен crate и попълнен формуляр, преди да можете да продължите към следващото гише.
Уинду е прецизен, старши, не се усмихва и ще ви спре на вратата, когато документацията не е наред. Освен това е прав почти всеки път, което е най-дразнещата част.
R2-D2 говори Elixir, C-3PO се занимава с генериране на код
Те пътуват заедно и решават един и същ проблем по противоположни начини, което ги прави най-полезната двойка в канона.
C-3PO превежда отвън. Той е вашият генератор на код, управляван от схема: давате му спецификация, той произвежда четиринадесет хиляди реда безупречен, коректен, напълно непрочетен код, споменава шансовете и всички продължават да си говорят, без да му обръщат внимание.
R2 не превежда. Той се включва в контакта и говори собствения език на машината отвътре.
Целият Elixir се състои от тройки елементи: операция, метаданни, аргументи. Всеки макрос е функция от тройки към тройки, изпълняваща се по време на компилация, което е всичко, от което някой се нуждае, за да конструира онова, което хората в Elixir конструират приблизително веднъж на две седмици:
defmodule Router do
defmacro __using__(_opts) do
quote do
import Router
Module.register_attribute(__MODULE__, :routes, accumulate: true)
@before_compile Router
end
end
defmacro get(path, handler) do
quote do: @routes {:get, unquote(path), unquote(handler)}
end
defmacro __before_compile__(env) do
for {verb, path, handler} <- Module.get_attribute(env.module, :routes) do
quote do
def dispatch(unquote(verb), unquote(path)), do: unquote(handler).()
end
end
end
end
defmodule MyApp do
use Router
get "/health", fn -> :ok end
get "/users", fn -> list_users() end
end
Всеки маршрут се превръща в собствена глава на функция, записана в модула, преди модулът да е приключил с компилацията си. След това диспечирането е съпоставяне на шаблони (pattern matching) във BEAM, без справочна таблица между заявката и работата. Това е приблизително начинът, по който го прави рутерът на Phoenix, и щом веднъж видите тройките, то престава да изглежда като магия и започва да прилича на подреждане на документи, каквото в крайна сметка се оказва, че е всяка инфраструктура под повърхността.
R2 притежава и другото качество. Никой не може да прочете изхода му, а корабът продължава да лети.
Люк Скайуокър е Nim
Пророчества от този вид винаги включват някой от земеделска планета, до голяма степен защото никой в столицата няма време.
Nim изпълнява същинския ви език по време на компилация. Не шаблонен подезик, не съпоставител на шаблони, който размества токени, а истинският език, изпълняван от виртуална машина вътре в компилатора, работещ върху дърво, което вече е преминало през проверката на типовете и следователно знае кое какво е.
import macros, strutils
type User = object
id: int
email: string
active: bool
macro insertFor(T: typedesc, table: static string): string =
var cols, slots: seq[string]
for field in T.getTypeImpl[1].getTypeImpl[2]:
cols.add $field[0]
slots.add "$" & $cols.len
newLit("INSERT INTO " & table & " (" & cols.join(", ") &
") VALUES (" & slots.join(", ") & ")")
const q = insertFor(User, "users")
# INSERT INTO users (id, email, active) VALUES ($1, $2, $3)
На макроса се подава самият тип, а не някой негов екземпляр, и обхожда списъка с полета, докато компилаторът все още обмисля нещата. Това, което се връща, е const, така че инструкцията седи в двоичния файл без нищо останало за изчисляване при стартиране.
Сега добавете колона към User и всяка инструкция, изградена по този начин, се променя при следващата компилация, без никой да редактира заявка. Махнете колона и кодът, който я е използвал, спира да се компилира, на реда, който я е използвал, много преди нещо да се доближи до база данни. Без ORM, без анотации, без рефлексия по време на изпълнение, без генерирани файлове, които чакат някой да си спомни за тях. Това е длъжностната характеристика на C-3PO, изпълнявана вътре в компилатора от дроид, който не говори.
Джар Джар Бинкс, и как всъщност се случват нещата
Вейдър обира лаврите. Вейдър е само фактурата.
Републиката не е била свалена. Тя е била премахната с гласуване от добронамерен идиот, който се изправил и предложил на Канцлера да бъдат предоставени извънредни правомощия, защото по онова време изглеждало полезно, а и всички били уморени.
Вашата кодова база има един такъв. Удобен малък макрос, добавен в петък, защото спестявал осем реда и всички се съгласили, че е доста хитър. Осемнадесет месеца по-късно половината система минава през него, поведението му е придобило три специални случая, които никой не е записал, и никой не си спомня да е гласувал за нищо от това. Никога не е бил обсъждан. Бил е приет, защото по онова време е бил очевидно полезен.
Това, с което се озовавате, е кодовата база тип „Вейдър“: повече генерирана, отколкото написана, технически жива, неприятна за стоене наблизо, поддържана от двама души, които са спрели да я обясняват на когото и да било.
Поуката за дръжката
Мечът е елегантен, защото компетентен човек го е изработил за собствената си ръка. Даден на някой, който не е положил труда, той отстранява крайник, и на практика почти винаги техния собствен.
Два навика ме държат от правилната страна на нещата. Първо напишете повтарящата се версия, повече от веднъж, докато формата спре да се мени, защото абстракция, положена върху движеща се мишена, е просто по-бърз начин да стигнете до грешния отговор. И вземете производствен stack trace от вашия красив нов език и го дайте на някой, който е започнал работа миналата седмица. Ако могат да стигнат от съобщението за грешка до реда, който я е причинил, без обиколка с екскурзовод, значи сте изградили светлинен меч. Ако не могат, значи сте изградили нещо, което ще се поддържа от суеверие дълго след като последният човек, който го е разбирал, е напуснал за работа с по-малко скоби.
Силата никога не е била проблемът. Нито веднъж не е била проблемът.
И така: от кой макрос във вашата кодова база всички тихомълком се страхуват и кой от тази пасмина го е написал?
Бележки
[1] Предполага се, че има и документация. Винаги има документация. Някъде в Храма е имало джедай, чиято цяла кариера се е състояла в заявки за кристали, и той не е получил филм.
[2] Объркването настъпва по-късно, на финала по дартс, когато кръчмата се запали.
[3] Критиците твърдят, че това означава „Lots of Irritating Superfluous Parentheses“ (Много дразнещи излишни скоби). Програмистите на Lisp са го чували. Програмистите на Lisp са го чували още преди родителите на критиците им да са се запознали.
[4] Винаги е неделя и винаги са плащания. Никой не знае защо. Има теории.