пятница, 16 сентября 2022 г.

[prog.c++] Хочется странного: структуры, которые можно инициализировать только посредством designated initializers

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

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

frames_buffer_t make_buffer(size_t frame_size, size_t frames_count);

При вызове такой функции запросто можно перепутать аргументы местами и написать:

auto buf = make_buffer(32, 1024);

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

Это касается не только подряд идущих аргументов одного типа, тоже самое можно отнести и к структурам, в которых подряд идут однотипные поля. Вроде вот такого:

struct capture_params_t {
  size_t m_frame_size;
  size_t m_frames_count;
  ...
};

Поскольку, опять же, если мы оперируем только примитивными типами, то глядя в код сложно понять, все ли с ним нормально:

capturing_stream_t make_stream(const capture_params_t & params) {
  auto buf = make_buffer(params.m_frames_count, params.m_frame_size);
  ...
}

Или даже так:

capture_params_t tune_params(const capture_params_t & default_values) {
  capture_params_t result{ default_values };
  if(has_enough_memory()) {
    result.m_frame_size = default_values.m_frames_count * 4u;
    ...
  }
  ...
  return result;
}

Тогда как в случае использования даже примитивных strong typedef подобные ошибки самим компилятором отлавливаются на раз. Ну и при чтении кода как-то все более понятно. ИМХО, конечно же.

Самый простой вариант в C++ после C++11 -- это что-то вроде:

struct frame_size_t { size_t m_value; };
struct frames_count_t { size_t m_value; }

frames_buffer_t make_buffer(frame_size_t frame_size, frames_count_t frames_count);

Что приводит к тому, что в коде уже приходится писать так:

auto buf = make_buffer(frame_size_t{32}, frames_count_t{1024});

И тут уже глядя на код можно задуматься, а нормально ли, что у нас в буфере большое количество маленьких фреймов? Может быть должно быть наоборот?

В общем, в последнее время пытаюсь регулярно применять strong typedef в том или ином виде.

Но время от времени сталкиваюсь со случаями, когда введение strong typedef в виде отдельных типов выглядит как overkill.

Из недавнего: мне потребовался небольшой класс, который должен был бы использоваться всего двумя-тремя методами. Класс вида:

class probe_result_t
{
    friend class data_collector_t;

    int m_read_pos;
    int m_write_pos;
    int m_size;

    probe_result_t(int read_pos, int write_pos, int size)
        : m_read_pos{read_pos}, m_write_pos{write_pos}, m_size{size}
    {}

public:
    [[nodiscard]] int size() const noexcept { return m_size; }
};

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

Но и не хочется превращать этот простой класс во что-то подобное:

class probe_result_t
{
    friend class data_collector_t;

    struct read_pos_t { int m_v; };
    struct write_pos_t { int m_v; };
    struct size_t { int m_v; };

    int m_read_pos;
    int m_write_pos;
    int m_size;

    probe_result_t(read_pos_t read_pos, write_pos_t write_pos, size_t size)
        : m_read_pos{read_pos.m_v}, m_write_pos{write_pos.m_v}, m_size{size.m_v}
    {}

public:
    [[nodiscard]] int size() const noexcept { return m_size; }
};

Как по мне, так отличным решением здесь могло бы стать использование вспомогательной структуры и добавленных в C++20 designated initializers:

class probe_result_t
{
    friend class data_collector_t;

    struct params_t
    {
        int m_read_pos;
        int m_write_pos;
        int m_size;
    };

    params_t m_v;

    probe_result_t(params_t params)
        : m_v{params}
    {}

public:
    [[nodiscard]] int size() const noexcept { return m_v.m_size; }
};

Создавать экземпляр probe_result_t можно было бы просто и наглядно:

probe_result_t data_collector_t::probe_collected_data()
{
    ...
    return { { .m_read_pos = rp, .m_write_pos = wp, .m_size = ds } };
}

Но вот беда... Нельзя в C++ указать, что экземпляр структуры можно проинициализировать только посредством designated initializers. Старый-добрый initializer list вполне себе будет работать:

probe_result_t data_collector_t::probe_collected_data()
{
    ...
    // Так можно написать, а хотелось бы, чтобы это было под запретом.
    return { { rp, wp, ds } };
}

Наверное, мне таки нужны именованные аргументы, которые есть в некоторых других языках программирования. Но в C++ именованных аргументов точно не будет. А вот designated initializers уже есть...

2 комментария:

Stanislav Mischenko комментирует...

Есть один хачок, но вам не понравится ;)

struct S {
int a;
int b;
float c;
}

S s{1,2,3.0f} // скомпилируется

а если так

struct S {
int a;
int b;
float c;

private:
S(std::initializer_list);
}

S s{1,2,3.0f} // error: 'S::S(std::initializer_list)' is private within this context

eao197 комментирует...

@Stanislav Mischenko

Прикольный трюк, спасибо, что поделились.

Я правда, не знаю, как его применить в нужном мне ключе. Но все равно прикольно.