В 2016 году меня пригласили помочь с разработкой экшн-очков "ORBI", это такие водонепроницаемые очки с несколькими камерами, которые могут стримить 360видео сразу на смартфон, ну а если с ними поплавать, тоже ничего сломаться не должно. (https://www.indiegogo.com/projects/orbi-prime-the-first-360-video-recording-eyewear#/). Собственно моей задачей было написать алгоритм склейки видео потока с четырех камер в одно большое 360* видео, на тот момент задача не очень сложная, но требующая немного специфичных знаний opencv и окружения. Но статья не об этом, потому что теперь это все оберегаемое IP, а про то как мы легальными и не очень средствами языка С++ писали тестовое окружение для используемых классов и соответственно алгоритмов. Да вы скажете, что там такого - сделал гетеры да тестируй себе на здоровье. А если гетера нет или переменная класса спрятана в private секцию и возможность изменить хедер отсутствует. Или вендор либы забыл положить хедеры, и прислал только скан исходников (китайские друзья они такие), а тестировать это надо? Помножив желание написать тесты на утренний кофф и приплюсовав дикий энтузиазм, можно получить очень много ошибок компиляции интересного опыта. Если нельзя, но очень хочется, то можно. Как говорил один мой знакомый лид: "Нет такого кода, который мы не сможем порефакторить, особенно за утренним кофф".
На начальном этапе разработки особо не задумывались над общей архитектурой, собрали прототип из свобдоного софта и какойто матери opencv + hugin + stitchEm, поэтому и тесты были раскиданы по всему проекту, включениями разной степени вредности. Ну главное что они хотя бы были и запускались.
К моменту презентации прототипа в конце 2016 года, один из основных классов девайса, который собирал 4 фрейма в один, был обвешан тестами по самое не могу, точно не помню, но чтото около 50 френдов для тестов. Повторюсь сделано это было не специально, так исторически сложилось, а потом стало общей практикой. Каждая неделя разработки добавляла еще пару тестовых друзей в этот класс, и в какойто момент решили что надо эту порочную практику убирать.
Я сейчас вам его покажу. Вот он, этот коварный тип гражданской наружности!
class OrbiVideo421 : public orbi::intrusive_ptr {
friend struct TestVideoStream;
friend struct TestVideoFrame;
friend struct TestVideoStitcher;
friend struct TestVideoPlayer;
friend struct TestVideoTest;
friend struct TestVideoStreamProcessor;
friend struct TestVideoCapture;
friend struct TestVideoWriter;
friend struct TestVideoProcessor;
friend struct TestVideoTestSuite;
friend struct TestVideoAnalyzer;
friend struct TestVideoConfiguration;
friend struct TestVideoBuffer;
friend struct TestVideoRenderer;
friend struct TestAudioStream;
friend struct TestAudioFrame;
friend struct TestAudioStitcher;
friend struct TestAudioPlayer;
friend struct TestPerformanceAnalyzer;
friend struct TestErrorLogger;
friend struct TestVideoFrameAnalyzer;
friend struct TestVideoSynchronization;
friend struct TestVideoMetadataExtractor;
friend struct TestVideoComparison;
friend struct TestVideoStreamController;
friend struct TestVideoStreamEncoder;
friend struct TestVideoStreamDecoder;
friend struct TestVideoStreamRouter;
friend struct TestVideoEffect;
friend struct TestVideoSegmentation;
friend struct TestVideoStreamRecorder;
friend struct TestVideoCompression;
friend struct TestVideoStreamSplitter;
friend struct TestVideoStreamSwitcher;
friend struct TestVideoStreamMetadataEditor;
friend struct TestVideoStreamAnalyzer;
// ниже еще около 40 френдов для тестов
}
Правильно, долго и дорого
class OrbiMemoryUnit : orbi::vtable_at_top {
friend MemoryTestRead;
friend MemoryTestWrite;
private:
nobject *_object;
uint32_t _reserved_memory;
}
Все было спрятано во внутренние структуры, отдельные друзья тесты использовали их по своему усмотрению. Но тут появились красивые мы, и решили сделать по науке, добавив гетеры к нужным мемберам класса. Конечно народ полез не только добавлять гетеры, но и немножко рефакторить код и имена. На горизонте замаячили пара недель мартышкиной работы, и не факт что она окажется полезной, потому что начинает меняться интерфейс класса в угоду тестам. К тому же нарушается правило "breaking changes" - менять без веской причины устоявшийся API - значит получить себе проблемы в будущем с совместимостью и сайдэффектами, которые фиг отловишь. Когда на ревью прилетает рефактор пополам с измененными именами переменных на 300+ строчек, то смотреть это было очень больно. И в итоге после пары созвонов практику эту прекратили, изменения откатили и сели думать как сделать по другому.
class OrbiMemoryUnit : orbi::vtable_at_top {
private:
nobject *_object;
uint32_t _reserved_memory;
public:
inline nobject *object () const { return _object; }
inline uint32_t reserved_capacity () const { return _reserved_memory; }
}
Плюсы такого подхода очевидны, все в рамках языковой модели, понятно новичкам на проекте. Минусы не так очевидны, а может это и не минусы даже, самый явный это необходимость продумывать нормальный API, что на небольших проектах с ограниченным ресурсом и временем растягивает выкатывание фич и отнимает время у команды - о чем мне и было заявлено ПМом на одном из совещаний. Вообщем правильно - но долго и дорого, поэтому неправильно.
Черная магия макросов, но иногда не компилится
Так мы и жили с этим наследием, пока в одно прекрасное утро на один из классов не наткнулся наш коллега из солнечной Катании, который недавно подключился к этому модулю проекта, решив там чего-то дописать. Он был очень unittest friendly погромистом, поэтому число тестов за утро увеличилось в полтора раза. А уже в обед на почту билд инженеру прилетело поздравление, с тем что число френдов в классе OrbiDeviceBattery превысило 100 штук (это мы потом уже выяснили, опытным путем) и компилятор Keil-C ниасилил это скомпилить, вывалив в лог вот такую ошибку.
C:\ent\orbi\prod\KeilMDK\INCLUDE\keillex\xstring(25) : error C2146: syntax error : missing ';' before identifier 'friend'
C:\ent\orbi\prod\KeilMDK\INCLUDE\keillex\xstring(597) : see reference to class template instantiation 'OrbiDeviceBattery<_E,_Tr,_A>' being compiled
C:\ent\orbi\prod\core\battery\device.cc(655) : error C2838: illegal qualified name in member declaration
Каким боком нешаблонный класс OrbiDeviceBattery стал шаблонным, и почему он задел xstring непонятно. Слова в логе об ошибке имеют мало отношения к самой ошибке, мы просто сломали компилятор. Написал саппорту разработчика компилятора и не получил внятного ответа, а побродив по форуму увидел, что подобные жалобы в кор команду компилятора имеют среднее время закрытия от полугода и больше, если зайдете на форум Keil то можно найти парочку, которые были открыты еще в 2017 и до сих пор статусе "незафикшено". Видимо пять лет полежало, и еще пять полежит. На 655 строке ожидаемо был еще один френд класса.
Поскрипев недолго серыми клеточками, которые не восстанавливаются, нашли вполне себе рабочий хак для тестового окружения. Можно переопределить private/class на public/struct. Вроде бы вот оно "щастье" - пиши тесты сколько душе угодно, безо всяких френдов. Но и тут нас ждал розовый птиц обломинго, то что собирается на clang-е, не обязательно будет работать на другом clang-е. Кеil компилятор радостно сообщил, что мы пытаемся переопределить ключевые слова, что как бы делать не стоит, от слова совсем.
#define private public // illegal
#define class struct // illegal
#include "core/view_direction.h"
#include "core/view_vec2i.h"
#include "core/view_point.h"
А вот gcc/clang такое вполне позволяют (https://onlinegdb.com/pag5bTK2Yv). Но вообще не стоит так делать, все равно что растить Пабликов Морозовых в своем проекте. Это мы от безысходности таким страдали. Делайте нормальные решения в рамках языковой модели, это вернется меньшим техдолгом, поверьте моему опыту.
#include <iostream>
using namespace std;
#define private public
#define class struct
class A { int l; };
int main() {
A a;
cout << a.l;
return 0;
}
Дедушка Паблика Морозова
Но задачка уже не хотела отпускать беспокойный мозг, тем более что стартап получил следующий раунд финансирования и можно было немного расслабиться и разогнув спину, заняться чем то более полезным, кроме написания этих ненавистных тестов да придумывания алгоритмов склейки. О проблеме нарушения прав доступа при работе с шаблонами писал Herb Suttter еще в 2010 году (http://www.gotw.ca/gotw/076.htm), но считал её скорее фичей языка на всякий пожарный.
У него даже появилось отдельное правило по этому случаю:
never subvert the language; for example, never attempt to break encapsulation by copying a class definition and adding a friend declaration, or by providing a local instantiation of a template member function (GotW #76), Herb Sutter
Вот пример от самого Саттера (https://onlinegdb.com/cn7bOdWdn), как можно сломать механизм инкапсуляции плюсов, с одной оговоркой, у класса должна быть свободная шаблонная функция в паблик зоне. Работает все достаточно просто, инстанцирование шаблонной функции класса дает нам доступ к приватным данным класса, потому что по факту она является функцией класса со всеми правами.
class X {
public:
X() : private_(1) { /*...*/ }
template<class T>
void f( const T& t ) { /*...*/ }
int Value() { return private_; }
private:
int private_;
};
namespace {
struct Y {};
}
template<>
void X::f( const Y& ) {
private_ = 2; // evil laughter here
}
int main() {
X x;
cout << x.Value() << endl; // prints 1
x.f( Y() );
cout << x.Value() << endl; // prints 2
}
В принципе на этом можно было и остановиться, потому для 90% случае это покрывает необходимости тестирования, и наши френды становятся просто свободными классами, которые будут параметром для вот такой прокси функции. Минимум изменения функционала, максимум пользы, т.е. минус все тестовые френды из хедера. Если бы не одно НО... не везде мы могли добавить такую шаблонную функцию, чтобы провернуть этот фокус.
Всех убил садовник
Еще немного поэкспериментировав с получением приватных членов класса, стало понятно что ни один из существующих компиляторов не палит получение адреса члена класса, если он будет приведен к другому типу данных, который не является приватным. Если мы попробуем получить адрес приватной переменной, то получим ошибку компилятора, что логично, компилятор то умнее нас и оберегает от выстрелов в разные части тела.
struct A {
private:
char const* x = "private data";
};
int main() {
auto ptr = &A::x;
}
...
main.cpp: In function ‘int main()’:
main.cpp:46:19: error: ‘const char* A::x’ is private within this context
46 | auto ptr = &A::x;
| ^
Но если мы аккуратно попросим компилятор дать возможность стрелять куда хочется адрес этой переменной в качестве параметра шаблона, то все будет хорошо. Мы ведь ничего далее с ним не делаем, так - плюшками балуемся.
struct A {
private:
char const* x = "private data";
};
template <class Stub, typename Stub::type x>
struct private_member_x {
private_member_x() { auto ptr = x; }
};
// так мы просим компилятор не обращать внимания на реальный тип переменной
struct A_x { typedef char const *(A::*type); };
// и теперь компилятор считает, что работает с другим типом (подменным)
template struct private_member_x<A_x, &A::x>;
int main()
{}
// все чисто, никаких ошибок компиляции
Итак, адрес приватной переменной у нас есть, остается совсем немного, чтобы написать обертку для хранения и работы с этими адресами. Например как то так (компилябельно) https://onlinegdb.com/-am_9JCwR
// шаблон для класса заглушки, который будет хранить адрес переменной
template <class Stub>
struct member { static typename Stub::type value; };
// статик переменная для общего шаблона
template <class Stub>
typename Stub::type member<Stub>::value;
// подмена типа и получение адреса приватной переменной
// stub уйдет дальше в класс member, чтобы имять статик переменную для этого типа
template <class Stub, typename Stub::type x>
struct private_member {
private_member() { member<Stub>::value = x; } // сохранение адреса переменной
static private_member instance;
};
// thanks to @mr_Ulman
template <class Stub, typename Stub::type x>
private_member<Stub, x> private_member<Stub, x>::instance;
// тесткейс
struct PapaPavlica {
private:
char const* papini_dengi = "papini dengi";
};
// подменный тип, чтобы компилятор не пугался нарушением прав доступа
struct A_x { typedef char const *(PapaPavlica::*type); };
// магия, здесь живут драконы
template struct private_member<A_x, &PapaPavlica::papini_dengi>;
int main() {
PapaPavlica papa;
std::cout << papa.*member<A_x>::value << std::endl; // papini_dengi
// разворачиваем обратно полученный адрес в переменную
// получаем *(papa).(&PapaPavlica::papini_dengi) = "deneg net"
papa.*member<A_x>::value = "deneg net";
std::cout << papa.*member<A_x>::value << std::endl; // deneg net
}
Этот код мы в прод не пустили, он так и остался уделом тестового окружения, но вписался там прекрасно. Ну и конечно, я не оставлю вас без работающей либы (https://github.com/altamic/privablic), купите при встрече нашему любителю юниттестов из солнечной Катании пива.