Уже в подростковом возрасте, будучи младшим программистом, я слышал много разговоров о метапрограммировании. Несмотря на то, что Википедии не существовало, а информация в Интернете в целом не была доступна в той степени, как сегодня, было легко найти определение метапрограммирования. Моя проблема заключалась в том, что это определение мало что мне говорило. За прошедшие годы я узнал гораздо больше о метапрограммировании. В этой статье блога я объясню, что такое метапрограммирование. Кроме того, я покажу различные примеры метапрограммирования.
Определение метапрограммирования
Итак, что такое метапрограммирование? Когда мы программируем, то есть пишем код программы, мы пишем код для программы. И наоборот, когда мы занимаемся метапрограммированием, мы пишем код для самого кода.
Звучит запутанно? Угадайте, как запутался я, когда мне было 14 лет и я пытался выучить все это самостоятельно…
Примеры часто помогают облегчить путаницу. Поэтому в этом посте я приведу много примеров. Давайте начнем с примера в псевдокоде.
Допустим, мы хотим вывести числа от 1 до 10. Даже начинающему разработчику придет в голову использовать цикл:
for i = 1 to 10:
print(i)
Но будет ли то же самое, если мы выведем все операторы печати?
print(1)
print(2)
print(3)
print(4)
print(5)
print(6)
print(7)
print(8)
print(9)
print(10)
Этот вопрос более сложен, чем может показаться на первый взгляд. Однако, поскольку оптимизация компилятора и архитектура наборов инструкций (ISA) не входят в сферу интересов этой заметки, давайте упростим ситуацию и предположим, что они будут выполняться так, как написаны. То есть, первый код более компактен, но второй выполняется быстрее, поскольку в нем нет ни сравнений, ни ветвлений. Типичный компромисс между размером и скоростью.
Однако читаемость кода тоже полезна. Итак, что если мы хотим написать первый вариант, но в итоге получить код от второго? Эту проблему может решить метапрограммирование. Например, наш псевдокод с метапрограммированием мог бы выглядеть следующим образом:
META_FOR(i, 1, 10):
print(i)
На нашем языке псевдокода это сгенерировало бы 10 последовательных print-выражений в коде.
Теперь, когда мы дали определение метапрограммированию и рассмотрели нереально простой пример, давайте рассмотрим несколько реальных примеров.
Директивы препроцессора языка Си
Многие начинающие программисты на языке Си могут сначала не понять этого, но директивы препроцессора Си, такие как #include
и #define
, являются метапрограммированием. Препроцессор языка Си, часто сокращенно называемый CPP, запускается перед выполнением компилятора языка Си.
Компилятор GNU C (GCC) поставляется с препроцессором C в комплекте. Чтобы запустить только препроцессор, не запуская компилятор, мы должны вызвать gcc
с -E
.
В качестве простого примера, давайте определим переменную VARIABLE_X
как 5
и используем оператор print для ее печати. Код может выглядеть следующим образом:
$ cat define-x.c
#define VARIABLE_X 5
printf("X = %dn", VARIABLE_X);
Теперь давайте запустим препроцессор и посмотрим на вывод. (Нам придется использовать переключатель -P
, чтобы избежать беспорядка, вызванного маркерами строк, которые препроцессор добавляет по умолчанию):
$ gcc -E -P define-x.c
printf("X = %dn", 5);
Препроцессор языка Си поддерживает условия. Мы можем написать код, который будет заканчиваться по-разному в зависимости от определений. Например, мы можем использовать #ifdef
для генерации чего-то одного, когда определена OS_WINDOWS
, и чего-то другого, когда определена OS_LINUX
. Обратите внимание, что нам нужно завершить условие #endif
.
$ cat ifdef-windows-linux.c
#ifdef OS_WINDOWS
printf("hello Billn");
#endif
#ifdef OS_LINUX
printf("hello Linusn");
#endif
Теперь, если мы не зададим ни того, ни другого, сгенерированный код будет пустым:
$ gcc -E -P ifdef-windows-linux.c
Если мы определим OS_WINDOWS
, то получим следующее:
$ gcc -E -P -D OS_WINDOWS ifdef-windows-linux.c
printf("hello Billn");
и с OS_LINUX
:
$ gcc -E -P -D OS_LINUX ifdef-windows-linux.c
printf("hello Linusn");
Мы можем делать математические утверждения в #if
‘s:
$ cat if-math.c
#if (6 * 7) == 42
printf("all goodn");
#else
printf("is math broken?n");
#endif
Вывод соответствует ожиданиям:
$ gcc -E -P if-math.c
printf("all goodn");
На самом деле, препроцессор языка Си очень мощный. В нем можно делать функции, циклы и так далее. Важно понимать, что если это возможно, то это не значит, что это хорошая идея. Некоторые программы, такие как ядро linux, активно используют препроцессор C, но большинство программ лучше использовать его по минимуму. Конечно, без операторов #include
многого не сделаешь, и заголовочные файлы нуждаются в защитных экранах include, но в остальном я рекомендую использовать препроцессор C экономно.
Шаблоны C++
C++ является надмножеством C, поэтому все макросы препроцессора C работают и для C++. (Технически, препроцессору C вообще не важен исходный код, и вы можете использовать препроцессор C для любого языка, который вам нравится).
В дополнение к препроцессору языка Си, в C++ есть шаблоны. В отличие от препроцессора Си, шаблоны Си++ являются встроенной конструкцией языка. Несмотря на это, шаблоны C++ являются метапрограммированием. Шаблоны C++ генерируют код, который должен быть скомпилирован.
Допустим, мы хотим создать функцию, которая возвращает сумму двух целых чисел. Однако по какой-то причине мы не хотим создавать параметризованную функцию, принимающую целые числа, а хотим жестко закодировать ее следующим образом:
int sum23()
{
return 2 + 3;
}
Теперь, если мы хотим создать много таких функций, нам придется написать много кода. Вместо этого мы можем использовать шаблоны, чтобы компилятор создавал код за нас:
template<int N, int M>
int template_sum()
{
return N + M;
}
Чтобы получить экземпляр функции sum23()
, мы должны сделать следующее:
int (*sum23)() = template_sum<2,3>();
Обратите внимание, что предпочтительнее сделать следующее
auto sum23 = template_sum<2,3>();
но я хотел сделать тип явным здесь для максимальной читабельности.
Наиболее распространенное использование шаблона C++ — это контейнеры. Скажем, вы хотите создать контейнер в виде связного списка. В C++ без метапрограммирования, то есть без шаблонов и препроцессора C, вам пришлось бы переписывать один и тот же контейнер для каждого класса, который вы хотите поддерживать. На практике это закончилось бы копированием большого количества кода, что, по меньшей мере, неоптимально.
Объявление нашей реализации связного списка может выглядеть следующим образом:
template<class T>
class linked_list;
Реализация связанного списка выходит за рамки данного поста. Однако, если предположить, что мы создадим реализацию, то мы сможем создавать связанные списки с целыми числами, строками и т.д.
linked_list<int> my_integer_list;
linked_list<std::string> my_string_list;
Обратите внимание, что шаблоны создают эквивалент исходного кода, который был скопирован, т.е. существуют отдельные классы для linked_list<int>
, linked_list<std::string>
и т.д. для всего, что инстанцируется где-либо. Более того, поскольку единицы компиляции, т.е. объектные файлы исходного кода, компилируются отдельно, это может значительно увеличить время компиляции. Существуют некоторые способы оптимизации, но они выходят за рамки данной статьи.
Хотя шаблоны C++ являются чрезвычайно мощными, я бы рекомендовал использовать их экономно. Сам я активно использую контейнеры стандартной библиотеки, поскольку они очень хороши, но я редко пишу шаблоны сам. Ошибки, связанные с шаблонами, обычно трудно устранить. В первую очередь потому, что компилятор может не знать, где ошибка — в коде шаблона или в использовании шаблона, так как виновником может быть и то, и другое.
Переопределение функций
В таких языках, как C, объявление функции назначает символ, используемый для обращения к функции. Каждое последующее объявление функции должно иметь тот же прототип, иначе компилятор будет жаловаться. Допустим, мы объявляем int my_sum(int, int)
, а затем int my_sum(char, char)
:
$ cat my_sum.h
#pragma once
int my_sum(int a, int b);
int my_sum(char a, char b);
Компилятор выбрасывает ошибку, как и ожидалось:
$ gcc my_sum.c
In file included from my_sum.c:1:
my_sum.h:3:5: error: conflicting types for ‘my_sum’
3 | int my_sum(char a, char b);
| ^~~~~~
my_sum.h:2:5: note: previous declaration of ‘my_sum’ was here
2 | int my_sum(int a, int b);
| ^~~~~~
Это имеет смысл. Мы хотим, чтобы все наши вызовы my_sum()
были однозначными. Что еще более важно, в таком языке, как C, функции не могут быть переопределены. Даже если мы напишем точно такую же реализацию дважды, как показано ниже:
$ cat my_sum.c
#include "my_sum.h"
int my_sum(int a, int b)
{
return a + b;
}
int my_sum(int a, int b)
{
return a + b;
}
компилятор выдаст ошибку:
$ gcc my_sum.c
my_sum.c:8:5: error: redefinition of ‘my_sum’
8 | int my_sum(int a, int b)
| ^~~~~~
my_sum.c:3:5: note: previous definition of ‘my_sum’ was here
3 | int my_sum(int a, int b)
| ^~~~~~
Однако, хотя это и помогает избежать путаницы, так не должно быть. Вместо функций рассмотрим обычные переменные в C. Предположим, мы объявляем целое число a
и первоначально устанавливаем его в 5
. Позже мы можем установить его в 7
, и это вполне допустимо. По сути, мы переопределяем переменные:
$ cat int_a.c
#include <assert.h>
int main(void)
{
int a = 5;
assert(a == 5);
a = 7;
assert(a == 5);
return 0;
}
Компиляция и запуск этой программы не работают, как и ожидалось:
$ gcc int_a.c && ./a.out
a.out: int_a.c:7: main: Assertion `a == 5' failed.
Aborted (core dumped)
Если мы изменим последнее утверждение на a == 7
, код будет работать нормально.
Почему мы можем переопределять переменные, но не функции? Разве имена, которые мы использовали для обращения к функциям, не являются символами, как и переменные?
Действительно, и во многих языках, таких как python и javascript, переопределение функций вполне допустимо.
Переопределение функций в Javascript
Давайте поговорим о javascript. Предположим, мы хотим написать функцию my_greeting()
, которая возвращает строку, содержащую приветствие. Например, сделаем реализацию следующим образом:
function my_greeting() {
return 'hello';
}
Теперь, если мы вызовем функцию и используем результат в качестве входных данных для console.log()
, будет выведено ожидаемое сообщение:
> console.log(my_greeting());
hello
В отличие от такого языка, как C, мы можем переопределить функцию по своему усмотрению. Допустим, мы чувствуем себя немного испанцами и хотим изменить приветствие соответствующим образом:
function my_greeting() {
return 'hola que tal';
}
Теперь, если мы используем тот же код, что и раньше, он вызовет новую реализацию:
> console.log(my_greeting());
hola que tal
Мы можем переопределять не только отдельные функции. Мы также можем переопределять функции, принадлежащие объекту. Допустим, у нас есть класс Banana
, в котором есть функция getColor()
, возвращающая 'green'
:
class Banana {
getColor() {
return 'green';
}
}
Инстанцирование этого класса и вызов getColor()
возвращает ожидаемый результат:
> my_banana = new Banana();
Banana {}
> my_banana.getColor();
'green'
Теперь мы можем изменить реализацию функции. Если мы хотим изменить реализацию единственного экземпляра класса, мы можем поступить следующим образом:
> my_banana.getColor = function() { return 'yellow'; }
[Function]
> my_banana.getColor();
'yellow'
Если же мы хотим изменить реализацию всех экземпляров класса, нам нужно изменить класс. Чтобы продемонстрировать это, давайте сначала создадим экземпляр Banana
и назовем его original_banana
:
> original_banana = new Banana();
Banana {}
> original_banana.getColor();
'green'
Теперь давайте изменим реализацию метода класса getColor
. Для этого нам нужно использовать prototype
:
> Banana.prototype.getColor = function() { return 'black'; }
Давайте создадим банан new_banana
и увидим, что он имеет новый цвет:
> new_banana = new Banana();
Banana {}
> new_banana.getColor();
'black'
Не только новый банан имеет новый цвет, но и ранее созданный банан использует новую реализацию:
> original_banana.getColor();
'black'
Однако вспомните, что мы изменили реализацию экземпляра my_banana
. Эта реализация остается нетронутой:
> my_banana.getColor();
'yellow'
Переопределение функций в Python
В Python мы можем переопределять функции аналогично тому, как мы это делали в javascript. Давайте сделаем пример приветствия в Python. Сначала мы определим исходную версию my_greeting
:
def my_greeting():
return 'hello'
Теперь мы можем вызвать его и вывести результат:
>>> print(my_greeting())
hello
Используя синтаксис лямбда в Python, мы можем переопределить функцию следующим образом:
>>> my_greeting = lambda: 'hola que tal'
Теперь, если мы вызовем ее, она будет использовать новую реализацию:
>>> print(my_greeting())
hola que tal
Аналогично, в Python мы можем переопределять функции экземпляров классов. Давайте определим класс Banana
:
class Banana:
def get_color(self):
return 'green'
Создадим экземпляр Banana
, назовем его my_banana
и вызовем get_color()
:
>>> my_banana = Banana()
>>> my_banana.get_color()
'green'
Далее давайте переопределим get_color
. Для этого нам нужно импортировать types
и затем вызвать types.MethodType
:
>>> import types
>>> my_banana.get_color = types.MethodType(lambda self: 'yellow', my_banana)
>>> my_banana.get_color()
'yellow'
Изменение реализации для всех экземпляров класса аналогично. Вместо того чтобы ссылаться на экземпляр, мы ссылаемся на класс. Создадим экземпляр original_banana
, затем изменим реализацию Banana.get_color
, создадим экземпляр new_banana
и вызовем все get_color()
:
>>> original_banana = Banana()
>>> Banana.get_color = types.MethodType(lambda self: 'black', Banana)
>>> new_banana = Banana()
>>> original_banana.get_color()
'black'
>>> new_banana.get_color()
'black'
>>> my_banana.get_color()
'yellow'
Хотя original_banana
был создан до переопределения get_color
, он использует новую реализацию. Однако пользовательская реализация в my_banana.get_color
остается нетронутой.
Динамические классы Python
В Python есть понятие метаклассов. Используя метаклассы, мы можем динамически создавать классы.
Метаклассы имеют довольно нишевое применение. Поэтому они нужны в основном при разработке библиотек и тому подобных приложений. Я покажу пример того, как можно использовать метаклассы. Однако этот пример довольно длинный и, что более важно, не является рекомендуемым. Отказавшись от ответственности, мы готовы приступить к работе.
Допустим, мы хотим создать классы для различных моделей автомобилей. Обычным (и предпочтительным) способом будет создание класса «Автомобиль», который затем будет включать различную информацию, связанную с автомобилем, такую как двигатель
, цена
и все, что необходимо. Кроме того, для нашего примера, допустим, у нас также есть классы для производителя:
class Model:
def __init__(self, engine, price):
self.engine = engine
self.price = price
class Manufacturer:
def __init__(self, models):
self.models = models
Теперь, допустим, мы получим некоторые данные, в соответствии с которыми создадим экземпляры, то есть объекты, наших классов:
cars_dict = {
'Audi': {
'A5': {'engine': 'ICE', 'price': 46000},
'S5': {'engine': 'ICE', 'price': 55000},
'e-tron': {'engine': 'electric', 'price': 102000},
},
'Porsche': {
'911': {'engine': 'ICE', 'price': 214000},
'Taycan': {'engine': 'electric', 'price': 194000},
},
}
Эти данные можно было бы прочитать в функции следующим образом:
def create_cars_conventional(cars_dict):
cars = {}
for manufacturer in cars_dict:
models = {}
for model in cars_dict[manufacturer]:
models[model] = Model(cars_dict[manufacturer][model]['engine'],
cars_dict[manufacturer][model]['price'])
cars[manufacturer] = Manufacturer(models)
return cars
Мы можем вызвать эту функцию и затем запросить данные у различных экземпляров:
>>> cars = create_cars_conventional(cars_dict)
>>> audi = cars['Audi']
>>> a5 = audi.models['A5']
>>> a5.engine
'ICE'
>>> a5.price
46000
Кроме того, мы можем напрямую запрашивать информацию о конкретных моделях:
>>> cars['Audi'].models['A5'].engine
'ICE'
Однако, что если мы хотим создавать классы, а не экземпляры автомобилей? Мы можем сделать это с помощью метапрограммирования. Поскольку в Python классы являются гражданами первого класса, класс — это объект. Чтобы создать объект класса, мы вызываем type()
с именем класса, наследуемыми базовыми классами и словарем. Например, наш класс «Audi A5» может выглядеть следующим образом:
>>> A5 = type('A5', (), dict(engine='ICE', price=46000))
и теперь мы можем использовать его следующим образом:
>>> A5.engine
'ICE'
Однако, вероятно, мы хотели бы иметь функцию типа create_cars_conventional
, которая создает все классы за нас. Это может выглядеть следующим образом:
def create_cars_classes(cars_dict):
classes_all = types.SimpleNamespace()
for manufacturer in cars_dict:
classes_models = {}
for model in cars_dict[manufacturer]:
class_name = 'Car{}{}'.format(manufacturer, model)
car_engine = cars_dict[manufacturer][model]['engine']
car_price = cars_dict[manufacturer][model]['price']
car_class = type(class_name, (), dict(engine=car_engine, price=car_price))
setattr(classes_all, class_name, car_class)
return classes_all
Теперь ее можно использовать следующим образом:
>>> cars = create_cars_classes(cars_dict)
>>> mycar = cars.CarAudiA5
>>> mycar.engine
'ICE'
>>> mycar.price
46000
Как уже было сказано, это в основном нишевая функция, которая никогда не понадобится большинству программистов. Но она может пригодиться при разработке библиотек и т.д.
Заключение
В этой статье я дал определение метапрограммированию. Кроме того, я показал примеры того, что квалифицируется как метапрограммирование. По мере продвижения к более сложным примерам, различие между тем, что является и не является метапрограммированием, становилось все менее очевидным. В конечном счете, я хотел показать, что метапрограммирование может быть несколько эзотерическим. Более того, я хотел показать, что метапрограммирование редко бывает необходимо, и в первую очередь следует рассматривать решения без метапрограммирования. Метапрограммирование в основном полезно в библиотечных и подобных сценариях, где мы эффективно расширяем сам язык программирования.