Прошу прокомментировать код

Здравствуйте!

Прошу прокомментировать следующий код

#pragma once

#include <Arduino.h>

#define BPR_PULSE_MS 200
#define BPR_PAUSE_MS 0
#define BPR_REPEAT_COUNT 1  // 0 - forever

#define BPR_ON (_is_inverse ? LOW : HIGH)
#define BPR_OFF (_is_inverse ? HIGH : LOW)

class Beeper {
  
public:
  Beeper(const uint8_t pin, const bool is_inverse = false) {
    _pin = pin;
    _is_inverse = is_inverse;

    pinMode(_pin, OUTPUT);
    digitalWrite(_pin, BPR_OFF);

    init(BPR_PULSE_MS, BPR_PAUSE_MS, BPR_REPEAT_COUNT);
    _is_active = false;
  }

  void execute(void) {
    static uint32_t time_mark;

    if (!_is_active) return;

    uint32_t current_time = millis();
    switch (_step) {
      case 0:
        digitalWrite(_pin, BPR_ON);
        time_mark = current_time;
        _step = 1;
        break;
      case 1:
        if ((uint16_t)(current_time - time_mark) >= _pulse_ms) {
          if (_pause_ms) {
            digitalWrite(_pin, BPR_OFF);
            time_mark = current_time;
            _step = 2;
          } else {
            _step = 3;
          }
        }
        break;
      case 2:
        if ((uint16_t)(current_time - time_mark) >= _pause_ms) {
          _step = 3;
        }
        break;
      case 3:
        if (!_repeat_count /*beep forever*/ || ++_repeat < _repeat_count) {
          _step = 0;
        } else {
          digitalWrite(_pin, BPR_OFF);
          _is_active = false;
        }
        break;
    }
  }

  void start(const uint16_t pulse_ms = BPR_PULSE_MS, const uint16_t pause_ms = BPR_PAUSE_MS, const int8_t repeat_count = BPR_REPEAT_COUNT) {
    if (!pulse_ms) return;

    init(pulse_ms, pause_ms, repeat_count);
    _is_active = true;

    execute();
  }

  void stop(void) {
    digitalWrite(_pin, BPR_OFF);
    _is_active = false;
  }

private:
  void init(const uint16_t pulse_ms = BPR_PULSE_MS, const uint16_t pause_ms = BPR_PAUSE_MS, const int8_t repeat_count = BPR_REPEAT_COUNT) {
    _pulse_ms = pulse_ms;
    _pause_ms = pause_ms;
    _repeat_count = repeat_count;
    _step = 0;
    _repeat = 0;
  }

private:
  bool _is_active, _is_inverse;
  uint8_t _pin, _step, _repeat, _repeat_count;
  uint16_t _pulse_ms, _pause_ms;
};

Это класс бипера. У бипера есть активный уровень (импульс), когда он пищит, есть пауза для молчания. Задается в мсек. Также можно задать количество повторов последовательности импульсов и пауз. Если в процессе работы бипера пришла новая команда пищать, он исполняет новую.
Запускается вызовом Beeper::start(…), вызывается в основном цикле по Beeper::execute().

Пример:

#include "beeper.h"
#define PIN_BPR 5
Beeper bpr(PIN_BPR);

void setup() {
}

void loop() {
  if (/*какое-то условие*/) {
    bpr.start(500, 200, 5); // 5 повторений импульса в 500 мс и паузы в 200 мсек
  }

 bpr.execute();  // выполнение программы бипера (если неактивен, то просто выход из нее)
}

Несколько вопросов:

  1. Инициализация бипера и старт его работы предполагаются при постоянных параметрах бипера (импульс, пауза, количество повторов). Уместно ли здесь ключевое слово const, в т.ч., для булевых параметров? Или в данном случае его применение не нужно (не обязательно)?
  2. В функции execute() есть ли смысл менять порядок объявления переменной static и проверки активности бипера? Вообще, если при объявлении переменной я использую директиву static, означает ли это, что память под нее выделяется сразу при первом вызове функции вне зависимости от того дошла да нее программа или она выделится впервые только, если выполнится условие перед объявлением переменной в примере (функция execute())?
  3. А если я использую в функции переменную без static (предположим, что в вышеуказанном примере uint32_t time_mark объявлена без static ), то под нее выделяется память всегда при вызове функции (а потом освобождается при выходе из нее)? Вне зависимости от того, в каком месте функции она объявлена?
  4. По самому алгоритму бипера и его оптимальности (реализации), также прошу высказать замечания.

На этот и другие похожие вопросы лучше Страуструпа тебе никто не ответит. Теперь же ты знаешь что делать?

Вы сами писали этот класс?

Да, сам.

Это хорошо.

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

Вы (как, впрочем, и большинство авторов ардуиновских “библиотек”) злоупотребляете спецификатором доступа private. Вы можете объяснить, почему Вы его использовали здесь? По-моему, в нём нет никакой нужды. Но этим Вы убили возможность создания класса-наследника, т.е. это противоречит самой идее ООП.

Нет, я понимаю, что внутренним переменным не стоит быть public, но private их надо делать только в том случае, если Вы твёрдо уверены, что никаким классам-наследникам не нужен доступ к ним. В Вашем же случае – реально нужен почти ко всему. А значит, их надо было объявлять protected.

Посмотрите, как в Arduino IDE реализован класс String – его делали грамотные ребята.

Чуть позже я посмотрю код повнимательнее и оттопчусь на том, что замечу, не исчезайте.

Я убрал под спецификатор private функцию, которая вызывается только внутри публичных членов класса, а также все внутренние переменные, используемые классом. Думал, что так правильно.
Ваше замечание мне понятно. Реализацию String посмотрю обязательно и попробую в ней разобраться.

Ну, и хорошо. protected также прячет всё от внешнего доступа, но оставляет доступ для классов-наследников. Поэтому, в грамотно написанных классах это слово встречается гораздо чаще, чем private.

Можно еще вопрос?

Следующая структура написана на основе Вашего кода в теме про инициализацию структур.

struct SParam: public Printable {
  const int16_t &value;     // ссылка на переменную
  const uint8_t precision;  // количество знаков после точки
  const char * const name;  // название параметра
  const char * const unit;  // единица измерения

  // инициализируем на этапе компиляции и больше не меняем
  constexpr SParam(const int16_t &v, const uint8_t p, const char * const n, const char * const u) : value(v), precision(p), name(n), unit(u) {}

  // потоковый вывод параметра
  size_t printTo(Print &p) const {
    size_t res = p.print(name);
    if (precision) {  // если количество знаков после точки не ноль, то выделяем целую и дробную части
      const uint8_t del = 10 * precision;
      const int val = value / del;
      const int fract = value % del;
      res += p.print(val);
      res += p.print(".");
      res += p.print(fract);
    } else {
      res += p.print(value);
    }
    res += p.print(unit);

    return res;
  }
};

#define TEMP_SET 35
#define TEMP_CRITICAL 60

int16_t temp_current,
  temp_set = TEMP_SET,
  temp_critical = TEMP_CRITICAL,
  fan_rmp,
  pwm_duty;

SParam  prm_temp_current(temp_current, 1, "T=", " °C"),
        prm_temp_critical(temp_critical, 0, "Tc=", " °C"),
        prm_temp_set(temp_set, 0, "Ts=", " °C"),
        prm_fan_rpm(fan_rmp, 0, "RPM=", ""),
        prm_pwm_duty(pwm_duty, 0, "PWM=", " %");

void setup() {
  Serial.begin(115200);

  temp_current = (int16_t) random(350, 450);
  Serial.println(prm_temp_current);
}

Поля структуры (в том числе ссылка на переменную) инициализируются на этапе компиляции и в программе не изменяются.
Правильно ли в данном случае я применяю спецификаторы constexpr и const?

Ну, давайте, начнём помолясь …

Я буду показывать то, что считаю ошибкой, даже если оно не приводит к неправильной работе программы «здесь и сейчас». Это может быть общеопасная практика или действие, которое на ровном месте сильно ограничивает функционал программы без необходимости – всё это вместе буду называть ошибками. Если короче – ошибками я называю то, что считаю таковыми.

1.

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


Beeper quietBeeper(PIN_BPRQ), loudBeeper(PIN_BPRL);

Иначе просто непонятно зачем писать класс, ради одного экземпляра.

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

2.

Переменные (свойства) _pulse_ms, _pause_ms и _repeat_count никогда и нигде не изменяются, зато исправно отжирают 5 байтов памяти на экземпляр и время на бесполезное присваивание им одних и тех же значений при каждом вызове метода start. Зачем? Почему не использовать определённые в строках №№5-7 макросы напрямую (о макросах ниже).

3.

Макросы в строках №№ 5-7. Так писали лет 50-60 назад. Сейчас так пишут только старпёры, которые, до появления С++, много лет писали на Си и привыкли. Ну, или в особых случаях, например, если этот .h файл должен включаться и в программы на С++, и в программы на Си (или gnu-ассемблере). В С++ это пишется через constexpr переменные, что сразу же даёт контроль типов, т.к. у такой переменной указывается тип.

4.

Это не ошибка, просто я хочу убедиться, что Вы осознанно пишете «#pragma once», понимая, чем это отличается от более традиционного «#ifndef / #define / #endif». Понимаете или просто пишете потому, что где-то увидели?

5.

Вы завели переменные time_mark и current_time типа uint32_t только для того, чтобы везде (я имею в виду строки №№ 39 и 50) преобразовывать их к типу uint16_t. Зачем? Если Вам достаточно 16-битного millis, так и используйте этот тип везде (в т.ч. и в строках №№ 27 и 31) и не надо будет везде преобразовывать.

6.

Строки №№ 9 и 10. Верно всё, что сказано в п.3 про макросы. Если Вам не надо, чтобы это также читалось в программе на Си, то такие вещи пишутся inline-функцями. Плюс – контроль типов. И вообще, кроме вышеупомянутой совместимости, нет ни одной причины использовать макросы, а не inline-функции.

6.

Это не ошибка, т.к. очевидно, что Вы именно так и хотели сделать, но я решительно не понимаю, почему длительность импульса, длительность паузы и количество импульсов забиты намертво, а не параметры конструктора. Это позволило бы иметь несколько экземпляров с разными длительностями.

7.

Наконец, заметьте, все методы Вашего класса – inline. Это сделано умышленно? Если да, то зачем Вы завели поля (свойства) _is_inverse, _pin, _repeat_count, _pulse_ms и _pause_ms? Они ведь никогда не меняются во время исполнения, так сделайте их параметрами шаблона – тогда они не будут занимать память (а сейчас занимают). Да, при этом на каждый экземпляр будут создаваться свои функции, т.к. какая Вам разница, если они у Вас всё равно inline? Зато сэкономите память.

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

Многое из того, что Вы написали, мне пока не до конца понятно. Дайте немного времени разобраться. После чего приведу исправленный в соответствии с замечаниями код. И постараюсь ответить на Ваши вопросы.

Эммм, тоже не понял - она объявлена внутри метода, т.е. и доступна только внутри метода, а сам метод не static. Не имеет значения?

Ну, так-то нормально, но, раз уж всё определяется при компиляции и больше никогда не меняется, так и вычисления в функции printTo надо провести при компиляции, зачем там всё это добро вычислять? Там надо всё заранее вычислить (именно на этапе компиляции) и при вызове она пусть только готовое в поток выводит.

Но, сначала надо там возможную ошибку поправить. В строке №14 используется тип uint8_t. Но его хватит только на два знака (precision <= 2), при трёх уже переполнится. Это так и задумано? Если нет и нужно допускать хотя бы три, то надо другой тип использовать.

Совершенно верно.

И?

Переменная-то static, значит, одна на всех.

Вот смотрите:

struct Kaka {
	void someMethod(void) {
		static int commonShit = 0;
		Serial.println(++commonShit);
	}
};

Kaka kk1, kk2; // два экземпляра Кака

void setup() {
  Serial.begin(9600);

  Kaka kk3, kk4; // Ещё два экземпляра Кака, на этот раз локальные

  kk1.someMethod();
  kk2.someMethod();
  kk3.someMethod();
  kk4.someMethod();
  kk1.someMethod();
  kk2.someMethod();
  kk3.someMethod();
  kk4.someMethod();
}

void loop() {}

Результат:

1
2
3
4
5
6
7
8

Видите – четыре экземпляра используют одну и ту же переменную commonShit.

2 лайка

Почему-то считал, что это относится только к полям и методам класса. Спасибо ))

Эпиграф: “О сколько нам открытий чудных” (с)

На самом деле до сих пор я писал скетчи без ООП, классы использовал только готовые из сторонних библиотек. Ну, пару раз делал наследников с каким-то дополнительным несложным функционалом.
Сейчас потребовалось сделать модуль управления вентилятором, а готовое решение (коего на github хоть пруд пруди) применять… хочется все же своего.
А также немного и в с++ погрузиться.
В данном проекте наследование Beeper не планируется. Вполне можно было обойтись и функциями. Но, вот хочется мне странного…

А вот тут для меня полный шок. Полез читать learn.microsoft.com и… (см. эпиграф).
Цитата оттуда:

Такого никак не ожидал.

Вот тут мне совсем непонятно. Эти переменные хранят значения импульса, паузы и количества повторений и определяются при вызове Beeper::start(). Я планирую вызывать этот метод с разным набором параметров (константы).

#include "beeper.h"
#define PIN_BPR 5
Beeper bpr(PIN_BPR);

void setup() {}
void loop() {
  if (/*условие 1*/) {
    bpr.start(500, 200, 5);
  }
  if (/*условие 2*/) {
    bpr.start(200, 0, 1);
  }
  bpr.execute();
}

Макросы я использую только для начальной инициализации переменных _repeat_count, _pulse_ms и _pause_ms. Чтобы, например, можно было просто вызвать bpr.start() с дефолтными параметрами.

С constexpr я еще не разобрался, пока в процессе изучения.

Директива #pragma once вроде как говорит компилятору, что заголовочный файл должен быть подключен только 1 раз.
Я считаю, что #pragma once по сути эквивалентна

#ifndef _HEADER_H_
#define _HEADER_H_
int i;
#endif

но не

#ifndef _HEADER_H_
#define _HEADER_H_
int i;
#else
extern int i;
#endif

Это не так?

Я завел их с таким типом, потому что millis() возвращает uint32_t. А к типу uint16_t (которого мне достаточно для организации задержек) я привожу результат арифметических операций между этими переменными. Я даже не подумал, что значение, возвращаемое millis() можно сразу приводить к uint16_t и оперировать уже 2-х байтными переменными. А если рано или поздно millis() выйдет за 2 байта, то не будет ли здесь каких-то проблем? Хотя наверно нет. Там же просто старшие байты будут отбрасываться и счет дальше пойдет с нуля. А поскольку тип беззнаковый, то и вычитание current_time - time_mark нормально отработает.

С этим inline мне еще надо поразбираться. Например, для меня опять таки полной неожиданностью было, что

Подводя итоги, я еще не готов к исправлению исходного кода бипера. Разбираюсь дальше.

Да, так и задумано. В проекте больше 1 знака после запятой не предполагается.

Тут я не понял. Значение переменной, передаваемое структуре по ссылке меняется постоянно. Как минимум вот эти куски кода

const int val = value / del;
const int fract = value % del;
res += p.print(val);
res += p.print(".");
res += p.print(fract);

и

res += p.print(value);

я не понимаю как рассчитать на этапе компиляции.

Вот тут виноват, был невнимателен. Снимается вопрос. Как и последующий #7

ну, там чуть выше я привёл пример, где это демонстрируется.

Ну, слишком уж “по сути”. Разница есть, но до тех пор, пока Вы не планируете продвинутое управление сборкой, забудьте. Форма «#ifndef / #define / #endif » полезна, когда Вам надо вручную определять какие файлы включаются, а какие нет.

Нет. Если только знаковость сохранять, т.е. использовать uint16_t (или uint8_t)

Нет, ну в разумных пределах. То, что меняется, то не трогаете, а вот, например, 10 * precision – никогда не меняется, так чего его каждый раз считать? Умножайте прямо в конструкторе, он же constexpr - при компиляции умножит. Если там 0, то ничего страшного, ну умножите 0 на 10, делов-то!

Вот, прошу посмотреть. Немного упростил и убрал лишнее. Надеюсь, что правильно понимаю все, что тут написал…

#ifndef _BEEPER_H_
#define _BEEPER_H_

#include <Arduino.h>

constexpr uint16_t pulse_ms_default = 200;
constexpr uint16_t pause_ms_default = 0;
constexpr uint16_t repeat_count_default = 1;  // 0 - forever

class Beeper {
public:
  Beeper(const uint8_t pin, const bool is_inverse);
  void execute(void);
  void start(const uint16_t pulse_ms, const uint16_t pause_ms, const int8_t repeat_count);
  void stop(void);

  bool isActive(void) const {  // inline
    return _is_active;
  }

protected:
  constexpr uint8_t setOn(void) {  // inline
    return _is_inverse ? LOW : HIGH;
  }

  constexpr uint8_t setOff(void) {  // inline
    return _is_inverse ? HIGH : LOW;
  }

protected:
  uint8_t _pin, _step, _repeat, _repeat_count;
  bool _is_active, _is_inverse;
  uint16_t _pulse_ms, _pause_ms, _time_mark;
};

Beeper::Beeper(const uint8_t pin, const bool is_inverse = false)
  : _pin(pin), _is_inverse(is_inverse) {

  pinMode(_pin, OUTPUT);
  digitalWrite(_pin, setOff());

  _is_active = false;
}

void Beeper::execute(void) {
  if (!_is_active) return;

  const uint16_t current_time = millis();
  switch (_step) {
    case 0:
      digitalWrite(_pin, setOn());
      _time_mark = current_time;
      _step = 1;
      break;
    case 1:
      if (current_time - _time_mark >= _pulse_ms) {
        if (_pause_ms) {
          digitalWrite(_pin, setOff());
          _time_mark = current_time;
          _step = 2;
        } else {
          _step = 3;
        }
      }
      break;
    case 2:
      if (current_time - _time_mark >= _pause_ms) {
        _step = 3;
      }
      break;
    case 3:
      if (!_repeat_count /*beep forever*/ || ++_repeat < _repeat_count) {
        _step = 0;
      } else {
        digitalWrite(_pin, setOff());
        _is_active = false;
      }
      break;
  }
}

void Beeper::start(const uint16_t pulse_ms = pulse_ms_default, const uint16_t pause_ms = pause_ms_default, const int8_t repeat_count = repeat_count_default) {
  if (!pulse_ms) return;

  _pulse_ms = pulse_ms;
  _pause_ms = pause_ms;
  _repeat_count = repeat_count;
  _step = 0;
  _repeat = 0;

  _is_active = true;

  execute();
}

void Beeper::stop(void) {
  digitalWrite(_pin, setOff());
  _is_active = false;
}

#endif

Теперь по структуре SParam. Вот так правильнее?

struct SParam : public Printable {
  const int16_t &value;     // ссылка на переменную
  const uint8_t precision;  // количество знаков после точки
  const char *const name;   // название параметра
  const char *const unit;   // единица измерения

  // инициализируем на этапе компиляции и больше не меняем
  constexpr SParam(const int16_t &v, const uint8_t p, const char *const n, const char *const u)
    : value(v), precision(p), name(n), unit(u), del(10 * p) {}

  // потоковый вывод параметра
  size_t printTo(Print &p) const {
    size_t res = p.print(name);
    if (precision) {  // если количество знаков после точки не ноль, то выделяем целую и дробную части
      const int val = value / del;
      const int fract = value % del;
      res += p.print(val);
      res += p.print(".");
      res += p.print(fract);
    } else {
      res += p.print(value);
    }
    res += p.print(unit);

    return res;
  }

protected:
  const uint8_t del;
};

Или, поскольку от структуры мне нужна только printTo() и конструктор, да и precision используется по сути только для расчета делителя, то даже вот так

struct SParam : public Printable {
  // инициализируем на этапе компиляции и больше не меняем
  constexpr SParam(const int16_t &value, const uint8_t precision, const char *const name, const char *const unit)
    : _value(value), _name(name), _unit(unit), _del(10 * precision) {}

  // потоковый вывод параметра
  size_t printTo(Print &p) const {
    size_t res = p.print(_name);
    if (_del) {  // если значение делителя не ноль, то выделяем целую и дробную части
      const int16_t val = _value / _del;
      const int16_t fract = _value % _del;
      res += p.print(val);
      res += p.print(".");
      res += p.print(fract);
    } else {
      res += p.print(_value);
    }
    res += p.print(_unit);

    return res;
  }

protected:
  const int16_t &_value;     // ссылка на переменную
  const char *const _name;   // название параметра
  const char *const _unit;   // единица измерения
  const uint8_t _del;        // делитель (1 байт, поддерживает максимум 2 знака после запятой), вычисляется на этапе компиляции
};

Блин, это у меня ниже экрана было и я уже успел написать про это прежде, чем прочитать :slight_smile:

Но, не зря я говорил, что если лишнее убрать, то заметится ещё что-нибудь. Заметилось!

Нельзя так беспечно относиться к остатку – он всегда имеет знак частного. Вот смотрите, я печатаю точно как Вы и точно с теми же типами данных – любуйтесь.

void setup(void) {
	Serial.begin(9600);

	const uint8_t _del = 10;
	const int16_t & _value = -156;
	
	const int16_t val = _value / _del;
	const int16_t fract = _value % _del;
	Serial.print(val);
	Serial.print(".");
	Serial.println(fract);
}

void loop(void) {}

//	РЕЗУЛЬТАТ
//	-15.-6

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

Но, и это не всё. У меня тут было как-то эссе – крик души о крайне коварной привычке смешивать в одних операциях знаковые и беззнаковые числа (как у Вас _value – знаковое, а _del – беззнаковое). Вот, казалось бы, ну смешали Вы и смешали, “ачётакова?”. А Вот Вы попробуйте в моём примере (или у себя) поменять тип _del с uint8_t на uint16_t – обещаю, что ахренеете от результата.