Обновить

На дворе C++23, а заставить компилятор проверять код всё ещё помогает только ассемблер

Уровень сложностиСложный
Время на прочтение22 мин
Охват и читатели16K
Всего голосов 7: ↑6 и ↓1+11
Комментарии22

Комментарии 22

Не совсем понял проблему. "Я хочу, чтобы все, кто инклудит вот этот файл, были бы обязаны вызвать вот такую функцию с каким-то своим аргументом". Зачем? Ваш файл содержит другие функции, которые будут вызваны. Нельзя ли сделать так, чтобы в первую вызываемую реально полезную функцию передавался этот аргумент?

Беседы с ИИ пересказывать, пожалуйста, не надо. И запятые, блин, научитесь их ставить!

Нельзя ли сделать так, чтобы в первую вызываемую реально полезную функцию передавался этот аргумент?

первая вызываемая функция это конструктор класса Sensor, как базового класса, например вот здесь: https://github.com/openbmc/dbus-sensors/blob/e09c58c3a6949f58091f24a59883f93932552c72/src/fan/FanMain.cpp#L632

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

Беседы с ИИ пересказывать, пожалуйста, не надо. И запятые, блин, научитесь их ставить

Взаимоисключающие параграфы©

Казалось бы, современный C++ дает нам море инструментов для контроля кода на этапе компиляции: static_assert, концепты, constexpr всё, что только можно.

Вот именно: казалось бы. Я одно время увлёкся consteval, потому что люблю препроцессить. Но очень быстро обломился. Мало того, что он сырой и обрезанный, и толком на нём не развернёшься, так ещё и плохо продуманный. Как раз в вопросах контроля кода. Мне попался однажды чужой фрагмент, где consteval-функция делала throw "Invalid blahblahblah";. Я подумал: ага, эта семантика мне знакома. Так, например, делает LESS/WebCompiler 2022+. Кидаем строку под видом исключения в compile-time — видим эту строку в списке ошибок компиляции. Щаззз! Сообщение при компиляции было: «Бида-бида, вылетело исключение, а почему — сам разберись». Почему нельзя было в стандарте закрепить требование превращать такие строки в ошибки компиляции?

Ладно, стал искать, чем заменить. Посоветовали попробовать static_assert. Ну, я попробовал и теперь имею вопрос: его вообще в принципе можно заставить делать что-то полезное? Потому что на практике ты пишешь какой-нибудь высокоуровневый валидатор, а ошибка происходит на пятом уровне вложенности, среди строительных кубиков, из которых состоят все валидаторы (DRY же). И понять, что́ именно не так, просто НЕВОЗМОЖНО.

Как всегда в таких случаях, буду рад узнать, что ошибаюсь. Что я просто не умею готовить это «море инструментов для контроля кода».

Ну вот я умею колдовать в компайл-тайме.

(И чтобы немножко себя простимулировать к дальнейшей писанине, - упомяну мой проект nenormal - исключительно чорная магия, демонстрация возможностей, не для продакшена)

Внезапно, но constexpr-функции могут содержать некоторое количество рантайма.

Другое дело, что ошибки в компайл-тайме сделать человекочитаемыми - это большое искусство.

Для внятной диагностики нужно

  • по возможности, всё обмазывать констрейнами

  • по возможности, делать констрейны на именованных концептах

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

  • использовать зависимые типы - это увеличивает многословность, но при ошибке показывает значение, с которым возникла проблема; ну и вообще, повышает типизацию

по возможности, делать констрейны на именованных концептах

А вы можете показать, как сделать семантику констрейна на именованных концептах вот для этого примера? Заранее спасибо.

А то я смутно надеялся, что когда в будущем случайно передам форматтеру аргумент незнакомого типа, компилятор скажет: вот тут ты передал тип, который не пролазит через концепт. И вот это будет настоящий констрейн, как я их представляю. В реальности же я в таких ситуациях получаю error C2672: no matching overloaded function found. Что технически верно, но от этого не легче.

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

А почему нельзя сделать сам экземпляр класса внешней ссылкой?

Можно ещё как синглтон реализовать. Функцию instance() сделать с параметром - именем файла, а если её вызвали второй раз с другим именем - или паника, или просто возвращаем уже созданный экземпляр.

GCC грешит избыточным выкидыванием кода, даже если оптимизации отключены. Clang в этом лучше.

Но в общем случае задача 100% покрытия тестами, кажется, в С++ не решаема принципиально. Особенно в случае широкого использования шаблонов.

Весьма вероятно, что volatile - ваш друг!

Компилятор не имеет права убрать присвоение volatile-переменной.

Я немного упростил код, но, надеюсь, не повредил существо дела:

Файл a.hpp:

#include <iostream>

extern bool tok;
//static inline const bool tok_received = tok;
static inline const volatile bool tok_received = tok;

inline void f(){
  std::cout << "\nHello!\n";
}

Файл main.cpp:

#include <iostream>
#include "a.hpp"

bool initTok(){
  std::cout << "\ntoken initialized!\n";
  return true;
}

// bool tok = initTok();

int main(){
  f();
}

g++ -O3 main.cpp - ошибка линкера (g++ 13.3.0, Ubuntu)

Если убрать volatile из объявления, ошибка линкера исчезает, инициализация не вызывается.

Если, наоборот, в main.cpp раскомментироваать определение флага tok, ошибка ичезает и инициализация вызывается.

Весьма вероятно, что volatile - ваш друг!

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

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

я прошу прощения за static inline в декларации переменной tok_received: профессиональная деформация, мне недавно пришлось писа́ть код, который одновременно компилируется в C и C++, а в этом случае static inline единственный спокойный вариант (хотя это сочетание для плюсов бессмысленно и редуцируется просто до static).

Но что интересно: если static убрать (а лучше так и сделать, чтобы не плодить инстансы переменной), то ошибка линкера возникает и без volatile! То есть компилятор не решается убрать переменную с external linkage, а линкер (возможно, прежде, чем ему придет идея убрать) отругивается на то, что её нечем инициализировать. Но это всё слишком тонко, поэтому надежнее всё же volatile оставить, даже в варианте без static (с одним inline).

Но что интересно: если static убрать (), то ошибка линкера возникает и без volatile!

static inline создает только один инстанс и это работает только с 17 стандарта:

Static Inline Class Member Variables (C++17 and newer)

Before C++17, defining a static class member required a declaration in the header file and a separate definition in exactly one .cpp file. static inline allows you to declare and initialize a static class member directly in the header file. [1, 2]

  • The Benefit: It guarantees that only one instance of the variable exists across the entire program, safely satisfying the One Definition Rule (ODR)

Я сам это не больно давно узнал, вроде как не надеялся что С++ развивается в нужном направлении местами, и не мог такого предположить пока мне пальцем не показали, тоже!

То что у вас ошибка возникает без static inline это от компилятора зависит! У меня НЕ возникает. Но volatile тут все таки не очень правильно использовать, правильно будет сделать как я в следующей статье написал: сложить все что относится к этой дополнительной функциональности по работе с файлами в этот первоначально инициализируемый объект-класс, и обращаться к этой функциональности через этот объект-класс и компилятор его не сможет выкинуть и будет требовать его создания не зависимо от компилятора и это будет подходить по определения разных умных шаблонов проектирования, типа:

инверсия управления, Внедрение зависимостей (Dependency Injection) / Локатор служб (Service Locator)

Strong Type / RAII для этапа линковки)

Singleton с особенностями.

Все можно найти по таким ключевым словам в следующей статье.

Но это всё слишком тонко, поэтому надежнее всё же volatile оставить, даже в варианте без static (с одним inline).

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

Инициализировать что-то строго до main - может быть узким местом. А вдруг понадобится конфигурировать из комстроки?

Кажется, есть достаточно простое решение без ассемблера.

#include <iostream>

struct InitToken {};

// user-defined
void init_token();

inline InitToken g_init_token = (init_token(), InitToken{});
inline InitToken get_init_token() { return g_init_token; }

struct Sensor {
    explicit Sensor(InitToken = get_init_token()) {}
};

// если не раскомментировать, то будет ошибка линкера
// void init_token() { std::cout << "init" << std::endl; }

int main() {
    Sensor s;
}

https://godbolt.org/z/E9eWM8Gej

Интересно, что ваше решение можно упростить почти до нуля (https://godbolt.org/z/GnbxYvKq5)

#include <iostream>

// user-defined
extern void init_token();

inline bool token_initialized = (init_token(), true);

// если не раскомментировать, то будет ошибка линкера
//void init_token() { std::cout << "init" << std::endl; }

int main() {

}

Судя по всему, компилятор не имеет права убрать инициализацию, имеющую явный побочный эффект (ибо справедливо считает, что меняется observable behavior программы).

Вот, кстати, демонстрация того, что inline-переменная инициализируется единожды, будучи определена в нескольких единицах трансляции.

https://godbolt.org/z/9K19Y1dr5

ну ей так и положено по стандарту :)

Стандартное и правильное решение решение - через объявление внешней функции без реализации. Для классов - абстрактным методом.

Вызов перед main решается через конструктор глобальной переменной класса, через "attribute((constructor));" или через "#pragma startup my_startup_code".

А не проще отказаться от статических методов вообще?

const Sensor sensor("MySensorConfigValue");
int main() {
    sensor.doWork(); // Выведет: Working with param: MySensorConfigValue
    return 0;
}

Простите за грубость, но статья откровенное Г. Возможно вы, как погруженный в тему, имеете представление о проблеме, а вот я, как читатель, так и не понял что именно вам нужно и почему вы этого не можете получить. Полагаю, очень не хватает иллюстрирующих суть происходящего примеров кода. Типа вот это есть, вот этого хотелось бы, вот из-за этого не получается.

Отдельно доставляют запрятанные под кат простыни диалога с ИИ. Они кому-то кроме вас интересны?

ИМХО, просто образец того, как писать статьи не нужно. Еще раз проссыте за грубость.

А я потом опубликую продолжение этого разговора в котором ИИ оправдывается-анализирует почему он не смог предложить мне, если не правильное решение

Может лучше не надо?

Уверен, что не понял суть ваших затруднений. Но сложилось впечатление, что вам нужно от класса Sensor сделать шаблонный класс-наследник. Который должен параметризоваться специальным классом с нужными вам свойствами. И в коде далее должен использоваться не Sensor, а этот шаблонный наследник. Что-то типа:

template< typename Props >
class BasicSensor : public Sensor {
  ...
  // Этот метод должен использоваться для получения пути
  // к тестовым файлам.
  [[nodiscard]] std::filesystem::path getTestDataPath() {
    // А вот главный трюк: это значение должен предоставлять класс Props.
    return Props::testDataPath;
  }
  ...
}

После чего для того, чтобы использовать BasicSensor пользователь должен определить соответствующий класс свойств со статическим членом внутри.

struct MySensorProps {
  static std::filesystem::path testDataPath;
};
...
class MySensor : public BasicSensor< MySensorProps > {
  ...
};

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

Если testDataPath не понадобится, то всё компилятор отлично выкинет.

Я не очень понял конкретных страданий автора, но догадываюсь, что существует проблема протокола работы с внешним миром.

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

Для этого есть разные способы.

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

Правда, этот токен надо где-то хранить.

Если в глобальной переменной, то возникает риск использовать неинициализированную переменную, причём необязательно даже это будет UB. А если принимать по ссылке и дальше не использовать, то оптимизирующий компилятор может выкинуть ненужное и избавить линкер от ошибок.

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

Можно попытаться напрячь линкер (что и задумал автор). Но пошёл по пути токенов. А там оптимизирующий компилятор подкараулил!

Я выше показал, как можно не прибегать к ассемблерной магии.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации