Ненормальное программирование

Когда private, но очень хочется public

21 сентября 20238 мин

В 2016 году меня пригласили помочь с разработкой экшн-очков «ORBI» — это такие водонепроницаемые очки с несколькими камерами, которые могут стримить 360-видео сразу на смартфон, ну а если с ними поплавать, тоже ничего сломаться не должно (страница проекта на Indiegogo). Собственно, моей задачей было написать алгоритм склейки видеопотока с четырёх камер в одно большое 360°-видео — на тот момент задача не очень сложная, но требующая немного специфичных знаний OpenCV и окружения. Но статья не об этом, потому что теперь это всё оберегаемое IP, а про то как мы легальными и не очень средствами языка С++ писали тестовое окружение для используемых классов и соответственно алгоритмов. Да, вы скажете, что там такого — сделал геттеры да тестируй себе на здоровье. А если геттера нет или переменная класса спрятана в private-секцию и возможности изменить хедер нет? Или вендор либы забыл положить хедеры и прислал только скан исходников (китайские друзья они такие), а тестировать это надо? Помножив желание написать тесты на утренний кофе и приплюсовав дикий энтузиазм, можно получить очень много ошибок компиляции интересного опыта. Если нельзя, но очень хочется, то можно. Как говорил один мой знакомый лид: «Нет такого кода, который мы не сможем порефакторить, особенно за утренним кофе».

Когда private, но очень хочется public

На начальном этапе разработки особо не задумывались над общей архитектурой, собрали прототип из свободного софта и какой-то матери OpenCV + Hugin + stitchEm, поэтому и тесты были раскиданы по всему проекту, включениями разной степени вредности. Ну, главное что они хотя бы были и запускались.

К моменту презентации прототипа в конце 2016 года, один из основных классов девайса, который собирал 4 фрейма в один, был обвешан тестами по самое не могу — точно не помню, но что-то около 50 френдов для тестов. Повторюсь, сделано это было не специально, так исторически сложилось, а потом стало общей практикой. Каждая неделя разработки добавляла ещё пару тестовых друзей в этот класс, и в какой-то момент решили, что надо эту порочную практику убирать.

Я сейчас вам его покажу. Вот он, этот коварный тип гражданской наружности!

Я сейчас вам его покажу. Вот он, этот коварный тип гражданской наружности!

Показать код (OrbiVideo421)
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 френдов для тестов
}

Правильно, долго и дорого

Показать код (OrbiMemoryUnit)
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-е. Keil-компилятор радостно сообщил, что мы пытаемся переопределить ключевые слова, что как бы делать не стоит, от слова совсем.

Показать код
#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 такое вполне позволяют (пример). Но вообще не стоит так делать — всё равно что растить Пабликов Морозовых в своём проекте. Это мы от безысходности таким страдали. Делайте нормальные решения в рамках языковой модели, это вернётся меньшим техдолгом, поверьте моему опыту.

Показать код
#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 Sutter ещё в 2010 году (GotW #76), но считал её скорее фичей языка на всякий пожарный.

У него даже появилось отдельное правило по этому случаю:

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

Вот пример от самого Саттера (ссылка), как можно сломать механизм инкапсуляции плюсов, с одной оговоркой — у класса должна быть свободная шаблонная функция в public-зоне. Работает всё достаточно просто: инстанцирование шаблонной функции класса даёт нам доступ к приватным данным класса, потому что по факту она является функцией класса со всеми правами.

Показать код (пример Саттера)
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()
{}

// всё чисто, никаких ошибок компиляции

Итак, адрес приватной переменной у нас есть, остаётся совсем немного, чтобы написать обёртку для хранения и работы с этими адресами. Например, как-то так (компилябельно — пример).

Показать код
// шаблон для класса-заглушки, который будет хранить адрес переменной
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
}

Этот код мы в прод не пустили, он так и остался уделом тестового окружения, но вписался там прекрасно. Ну и конечно, я не оставлю вас без работающей либы (altamic/privablic) — купите при встрече нашему любителю юнит-тестов из солнечной Катании пива.

Если нельзя, но очень хочется, то можно

Если нельзя, но очень хочется, то можно.

← Все статьи