Что такое метапрограммирование?

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

Определение метапрограммирования

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

Звучит запутанно? Угадайте, как запутался я, когда мне было 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
Enter fullscreen mode Выйти из полноэкранного режима

Мы можем переопределять не только отдельные функции. Мы также можем переопределять функции, принадлежащие объекту. Допустим, у нас есть класс 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
Enter fullscreen mode Выйти из полноэкранного режима

Аналогично, в 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'                      
Enter fullscreen mode Выйти из полноэкранного режима

Однако, что если мы хотим создавать классы, а не экземпляры автомобилей? Мы можем сделать это с помощью метапрограммирования. Поскольку в 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                                        
Войти в полноэкранный режим Выйти из полноэкранного режима

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

Заключение

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

Оцените статью
Procodings.ru
Добавить комментарий