Pull to refresh

CRTP: Пример на паттерне «Мост»

Reading time5 min
Views14K

Для кого

Эта статья рассчитана на тех, кто не сталкивался с идиомой CRTP (Curiously recurring template pattern), но имеет представление о том, что такое шаблоны в C++. Специфических знаний или твердого владения программированием на шаблонах для понимания статьи вам не понадобится.

Пусть у нас будет такая задачка:

Из сети приходит файл в одном из форматов: json или xml и мы хотим их распарсить и получить какую-то информацию. Решение напрашивается само собой - использовать паттерн мост для разделения интерфейса парсера и двух его реализаций, по одной на формат файла. Так, после определения формата файла, мы можем передавать в функцию парсинга нужную нам реализацию в виде указателя.

Схематичный пример

// Функция, принимающая реализацию абстрактного интерфейса Parser в виде указателя
// и файл, возвращающая данные в обработанном виде
ParsedDataType parseData(Parser* parser, FileType file);

int main() {
    FileType file = readFile();
    Parser* impl = nullptr;
    if (file.type() == JsonFile)
        impl = new ParserJsonImpl();
    else
        impl = new ParserXmlImpl();
    ParsedDataType parsedData = parserData(impl, file);
}

В таком классическом подходе есть несколько минусов:

  • Интерфейс Parser обязан иметь виртуальные функции, а как мы знаем, ходить в таблицу виртуальных методов дорого.

  • Интерфейс функции не так нагляден, как хотелось бы, если сравнивать, например, с функциональными языками с богатыми системами типов.

  • Необходимость работы с указателями (возможность нулевого указателя, выделение памяти в куче и другие связанные с этим минусы).

Попробуем избавиться от некоторых недостатков средствами шаблонов C++

CRTP - сложный снаружи, простой внутри паттерн, позволяющий заменить механику абстрактных классов и переопределения поведения функций на другой способ предоставления интерфейса, который мы рассмотрим ниже.

Смысл подхода тот же - мы предоставляем интерфейс для классов-потомков, которые будут его реализовывать, только делаем это немного иначе, на шаблонах, статически.

Интерфейс парсера

template <typename Implementation>
struct ParserInterface {

    ParsedData getData() {
        return impl()->getDataImpl();
    }

    ParsedID getID() {
        return impl()->getIDImpl();    
    }

private:
    Implementation* impl() {
        return static_cast<Implementation*>(this);
    }
};

В данном случае, интерфейс принимает реализацию, которая обязана быть его наследником для удачного приведения типов указателя на интерфейс к указателю на потомка в функции Implementation* impl().

В каждой функции интерфейса мы вызываем метод класса-потомка, который должен реализовывать необходимый функционал для вызывающей функции. Таким образом, если функция не реализована в классе-наследнике, то у нас будет ошибка компиляции.

А вот как выглядят реализации наших парсеров

Имплементации

struct ParserJsonImpl : public ParserInterface<ParserJsonImpl> {
    friend class ParserInterface;
private:
    ParsedData getDataImpl() {
        std::cout << "ParserJsonImpl::getData()\n";
        return ParsedData();
    }

    ParsedID getIDImpl() {
        std::cout << "ParserJsonImpl::getID()\n";
        return ParsedID;    
    }
};

struct ParserXmlImpl : public ParserInterface<ParserXmlImpl> {
    friend class ParserInterface;
private:
    ParsedData getDataImpl() {
        std::cout << "ParserXmlImpl::getData()\n";
        return ParsedData();
    }

    ParsedID getIDImpl() {
        std::cout << "ParserXmlImpl::getID()\n";
        return ParsedID();    
    }
};

Каждая реализация наследуется от интерфейса, причем параметризует его собой. Фактически, каждая из них наследует разный тип, так как ParserInterface<A>и ParserInterface<B>это разные типы. Однако на этом пока не стоит заострять внимание. Суть лишь в том, что то, что мы передаем в угловых скобках как параметр шаблона интерфейса - это та реализация, к которой будет приводиться тип интерфейса с помощью static_cast<>() в нашем случае в функции Implementation* impl(). А реализация должна быть наследником интерфейса, иначе приведение не отработает. И такая логическая цепочка приводит нас именно к такому подходу.

Рассмотрим структуру наших реализаций:

  1. Наследовались от интерфейса, параметризуя его собой - об этом выше.

  2. Для того, чтобы функции-реализации не попали в интерфейс пользователя, объявляем их в блоке private.

  3. Для того, чтобы класс интерфейса видел функции-реализации, объявляем его как friend.

Итак, паттерн почти готов, осталось только привести качественный пример его применения.

Функция парсинга файла

template <typename Impl>
std::pair<ParsedData, parsedID> parseFile(ParserInterface<Impl> parser) {
    return std::make_pair(parser.getData(), parser.getID());
}

Данный код наглядно показывает, что именно пимает функция. Параметр ParserInterface parser прямо просит передать в него реализацию интерфейса. Также мы не обязаны использовать указатели, так как static_cast для классов в одной иерархии, не имеющих виртуальных функций, легален.

Протестируем наш код:

int main() {

    ParserJsonImpl jsonParser;
    parseFile(jsonParser);

    ParserXmlImpl xmlParser;
    parseFile(xmlParser);      

    return 0;
}

Все работает как и ожидалось.

Вывод программы

ParserJsonImpl::getData()
ParserJsonImpl::getID()
ParserXmlImpl::getData()
ParserXmlImpl::getID()

Итак, мы ушли от работы с виртуальными функциями и выделения памяти в куче, а значит, немного увеличили скорость нашей программы. Обезопасили код тем, что дали возможность легального static_cast для приведения типов в иерархии. Также субъективно мы перешли к более наглядному интерфейсу пользователя функции. Однако, везде есть свои минусы:

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

  • Шаблоны достаточно сильно сковывают дальнейшее использование структур в данном случае: уже не будет так просто создать контейнер из потомков, наследующих интерфейс, так как фактически они наследуют разным классам, хотя возможность такая конечно же есть.

Эпилог

Данный подход применяется также для идиомы MixIn классов, которые "подмешивают" свое поведение классам наследникам. Один из таких классов - std::enable_shared_from_this - подмешивает функционал для получения указателя shared_ptr на себя самого.

В данной статье приведен самый простой пример для ознакомления с темой, дальше - больше.

Полный листинг рабочего кода

#include <iostream>


template <typename Implementation>
struct ParserInterface {
    int getData() {
        return impl()->getDataImpl();
    }

    int getID() {
        return impl()->getIDImpl();    
    }

private:
    Implementation* impl() {
        return static_cast<Implementation*>(this);
    }
};

struct ParserJsonImpl : public ParserInterface<ParserJsonImpl> {
    friend class ParserInterface<ParserJsonImpl>;
private:
    int getDataImpl() {
        std::cout << "ParserJsonImpl::getData()\n";
        return 0;
    }

    int getIDImpl() {
        std::cout << "ParserJsonImpl::getID()\n";
        return 0;
    }
};

struct ParserXmlImpl : public ParserInterface<ParserXmlImpl> {

    int getDataImpl() {
        std::cout << "ParserXmlImpl::getData()\n";
        return 0;
    }

    int getIDImpl() {
        std::cout << "ParserXmlImpl::getID()\n";
        return 0;    
    }
};

template <typename Impl>
std::pair<int, int> parseFile(ParserInterface<Impl> parser) {
    auto result = std::make_pair(parser.getData(), parser.getID());
    return result;
}


int main() {

    ParserJsonImpl jsonParser;
    parseFile(jsonParser);

    ParserXmlImpl xmlParser;
    parseFile(xmlParser);

    return 0;
}
Tags:
Hubs:
+7
Comments29

Articles