Цель сегодняшнего дня это рассмотрение идиомы Pimpl, подробности под катом.
Предлагаю сразу взглянуть на некий абстрактный пример ниже, состоящий из трех файлов board.h, board.cpp, main.cpp. Идея примера такова: у нас есть некоторый класс, который описывает печатную плату Board. У класса есть два метода, получить имя процессора и получить напряжение. В main.cpp мы просто выводим на экран эти параметры.
Содержимое board.h
#pragma once #include <iostream> #include <stm32_driver.h> class Board { public: Board(); std::string GetCpuName(); int GetVoltage(); private: Stm32_driver driver; }; |
Содержимое board.cpp
#include "board.h" std::string Board::GetCpuName() { return driver.GetCpuName(); } float Board::GetVoltage() { return driver.GetVoltage(); } |
Содержимое main.cpp
#include "board.h" int main(int argc, char *argv[]) { Board brd; std::cout << "Board cpu: " << brd.GetCpuName() << std::endl; std::cout << "Board voltage: " << brd.GetVoltage() << std::endl; return 0; } |
Особое внимание хочется обратить на класс Stm32_driver это некий надуманный магический класс реализация которого в данный момент нам не интересна. Будем считать, что это HAL/драйвер Stm32, который позволяет получать данные конкретно для stm32. Мы не знаем как он устроен внутри, для нас это черная коробка.
Важно что мы сейчас пишем для заказчика библиотеку по работе с платой, в которую сегодня может быть установлен один микроконтроллер, а завтра другой. Но пользователя платы это не должно волновать, ему же важно что он с платы может получить имя и напряжение.
Теперь предположим что у нас на плату может быть установлен камень Atmega. У нас снова появилась какая то библиотека, которая позволяет работать только с атмегой. Мы перепишем хедер так:
//Содержимое board.h #pragma once #include <iostream> #include <atmega_driver.h> class Board { public: Board(); std::string GetCpuName(); int GetVoltage(); private: Atmega_driver driver; }; |
Сразу возникает несколько вопросов. Первое, хочется более универсального решения. Если оставить два хедера, то пользователь нашей платы будет вынужден у себя подключать то один, то второй хедер. Это жутко неудобно.
Второе, если у нас 10 таких плат и в хедере нашлась ошибка, не вносить же в 10 файлов изменения.
Третье у нас к пользователю просачиваются библиотеки atmega_driver.h или stm32_driver.h и это плохо, так как внутри могут быть подключены библиотеки, которые нам совсем не нужны. Из за этого могут быть имен и классов, это создает много проблем,
Как бы мы могли победить проблему номер 1. Например так:
//Содержимое board.h #pragma once #include <iostream> #ifdef STM32 #include <stm32_driver.h> #elif ATMEGA #include <atmega_driver.h> #endif class Board { public: Board(); std::string GetCpuName(); int GetVoltage(); private: #ifdef STM32 Stm32_driver driver; #elif ATMEGA Atmega_driver driver; #endif }; |
Это, конечно, решает нашу проблему номер 1. Но, если это не тривиальный код из примера, а большой проект и если число поддерживаемых микроконтроллеров может вырасти, то поддерживать такой код будет очень сложно. Как нам сделать код платформонезависимым?
Тут нам на помощь придет идиома Pimpl. Основная ее идея в том, чтобы скрыть реализацию внутри cpp файла. Для начала объявим приватную структуру Impl и не будем говорить компилятору что это за структура и как она выглядит. Ее описание будет внутри cpp файла. Создать такой объект мы не можем, так как в пределах хедера не известен размер размер структуры. Чтобы обойти это ограничение мы создаем uniq_ptr такого типа. Таким образом мы говорим, что объект будет помещен внутрь uniq_ptr, когда то позже.
#pragma once #include <iostream> #include <memory> class Board { public: std::string GetCpuName(); int GetVoltage(); ~Board(); Board(); private: struct Impl; std::unique_ptr<Impl> pimpl; }; |
Самое интересное находится в board.cpp. Стоит отметить, что правильнее далее этот файл называть board_stm32.cpp, так как его реализация будет полностью соответствовать только stm32.
#include "board.h" #include <stm32_driver.h> struct Board::Impl { Stm32_driver driver; }; std::string Board::GetCpuName() { return pimpl->driver.GetCpuName(); } int Board::GetVoltage() { return pimpl->driver.GetVoltage(); } Board::Board() { } Board::~Board() = default; |
Как видно в примере, вся реализация функционала struct Impl класса находится целиком в cpp файле, т.е. пользователь класса Board.h не будет знать ничего про то, что внутри этого класса.
Для atmega мы сделаем аналогичный файл board_atmega.cpp с его особенностями специфичными для атмеги. Остается только подключить нужный файл на этапе линковки. Я использую cmake и там это в зависимости от типа переменной мы просто подключаем тот или иной cpp файл.
if (${TARGET_PLATFORM} STREQUAL "STM32") target_sources(${PROJECT_NAME} PRIVATE "board_stm32.cpp") elseif(${TARGET_PLATFORM} STREQUAL "ATMEGA") target_sources(${PROJECT_NAME} PRIVATE "board_atmega.cpp") endif() |
Данный пример немного надуманный, но он возможно будет понятен тем кто занимается электроникой, однако в реальности мне попались задачи, где две совершенно разные платформы, одна под железо другая под qemu эмулятор. Обе нужно было поддерживать одновременно.
В завершение хочется сказать чем еще хорош и плох piml? Из плюсов, как говорилось выше — сокрытие реализации. Еще один плюс это скорость компиляции. Так как структура Impl находится внутри cpp файла, то при работе с этой структурой изменения будут только в пределах этого файла, соответственно при перекомпиляции будет пересобран только cpp файл.
Это может показаться мелочью в небольших проектах, но в средних и больших проектах скорость пересборки может сильно повлиять на скорость разработки.
Главный минус pimpl это скорость доступа к данным. В первоначальном варианте, когда объект Stm32_driver driver лежал внутри класса Board доступ был бы быстрее, так как данные лежали бы в одном месте и поэтому кэшу процессора не нужно было бы лазить каждый раз по указателю.
Добавить комментарий