четверг, 14 марта 2024 г.

[prog.flame] Самодокументирующися код против документированного, наглядно

Намедни в LinkedIn поиронизировал на счет "самодокументирующегося кода". В очередной раз 😎

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

Подумал, что лучше одна иллюстрация лучше тысячи слов. Поэтому вот пример кода из реального проекта. Изменены только названия сущностей, все комментарии оставлены как есть. Плюс изъяты фрагменты, не относящиеся к самой иллюстрации.

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

Под катом оригинальный фрагмент с комментариями. Такими, какими они и были написаны при разработке. Но сперва этот же фрагмент, но вообще без комментариев. Именно так "самодокументирующися" код и выглядит, по моему (не)скромному опыту.

class DefaultThreadPoolScheduler final : public Scheduler
{
   ...

private:
   struct WorkerData
   {
      std::mutex _lock;
      std::condition_variable _wakeupCondition;

      Scheduler::TaskUniquePtr _taskToRun;

      bool _shutdownInitiated{ false };
   };

   using WorkerDataContainer =
      std::vector<std::reference_wrapper<WorkerData>>;

   std::latch _allWorkersStartedLatch;

   std::mutex _lock;

   TasksContainer _tasksQueue;

   ThreadPool _threadPool;

   WorkerDataContainer _availableWorkers;

   bool _shutdown{ false };
};

void DefaultThreadPoolScheduler::doWork() noexcept
{
   WorkerData thisWorkerData;

   {
      std::lock_guard schedulerLock{ _lock };
      _availableWorkers.push_back(std::ref(thisWorkerData));

      _allWorkersStartedLatch.count_down();
   }

   bool shutdownIntitiated{ false };
   while( !shutdownIntitiated )
   {
      std::unique_lock workerLock{ thisWorkerData._lock };

      if( TaskUniquePtr taskToRun = std::move(thisWorkerData._taskToRun); !taskToRun )
      {
         shutdownIntitiated = thisWorkerData._shutdownInitiated;
         if( !shutdownIntitiated )
         {
            thisWorkerData._wakeupCondition.wait(workerLock);

            shutdownIntitiated = thisWorkerData._shutdownInitiated;
         }
      }
      else
      {
         workerLock.unlock();
         taskToRun->run(Scheduler::RunCondition::Normal);
         completeTaskThenTryGetNext(std::move(taskToRun), thisWorkerData);
      }
   }
}

понедельник, 11 марта 2024 г.

[prog.c++] Первая попытка написать собственный концепт

В проекте для одного из клиентов уже используется C++20, так что есть возможность на практике прикоснуться к некоторым фичам двадцатого стандарта. Вроде ranges и concepts.

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

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

В общем, смысл в том, что есть интерфейс диспетчера задач Scheduler и есть интерфейс отдельной задачи Task:

class Scheduler
{
public:
   class Task
   {
   public:
      enum class Status { Normal, Deleted };

      virtual ~Task() = default;

      virtual void run(Status) = 0;
   };

   using TaskUniquePtr = std::unique_ptr<Task>;

   virtual void push(TaskUniquePtr task) = 0;
};

Существует несколько реализаций Scheduler-ов. В качестве же Task-ов планировалось использовать лямбда-функции, которые должны оборачиваться в реализацию интерфейса Scheduler::Task. Т.е. вместо того, чтобы делать как-то так:

class FirstTask final : public Scheduler::Task
{
  ... // Какие-то внутренности.
public:
  ... // Какой-то конструктор.

  void run(Status status) { ... /* Какие-то действия */ }
};
...
scheduler.push(std::make_unique<FirstTask>(...));

предполагалось делать как-то так:

make_task(scheduler,
  [...](Scheduler::Task::Status status) {... /* Какие-то действия */ });

И вспомогательный шаблон функции make_task должен был обернуть лямбду в некий класс-наследник Task. Что-то вроде:

template<typename H>
class LamdaAsTask final : public Scheduler::Task
{
   H _handler;

public:
   explicit LamdaAsTask(H handler) : _handler{std::move(handler)} {}

   void run(Status condition) override
   {
      _handler(condition);
   };
};

template<typename H>
void make_task(
   Scheduler & scheduler,
   H && handler)
{
   scheduler.push(std::make_unique<LamdaAsTask<H>>(std::forward<H>(handler)));
}

Но ситуация была чуть сложнее. Нужно было использовать разные фабрики для Task. Т.е. в make_task передавались не только лямбда и ссылка на Scheduler, но еще и некая фабрика. Именно фабрика брала лямбду и возвращала указатель на новый Task. Т.е. шаблон make_task выглядел так:

template<typename Factory, typename H>
void make_task(
   Scheduler & scheduler,
   Factory && factory,
   H && handler)
{
   scheduler.push(factory(std::forward<H>(handler)));
}

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

Вырисовывалось два концепта.

Первый концепт описывает лямбду-обработчик. Ну типа ее можно вызвать с нужным параметром и она возвращает void.

template<typename H>
concept Handler_C = requires(H h)
{
   { h(Scheduler::Task::Status::Normal) } -> std::same_as<void>;
};

Второй концепт описывает фабрику Task-ов:

template<typename F, typename H>
concept TaskFactory_C = requires(F f)
{
   requires Handler_C<H>;

   { f(H{}) } -> std::convertible_to<Scheduler::TaskUniquePtr>;
};

Т.е. этот концепт зависит от параметра H, который описывает лямбду. Эта лямбда должна удовлетворять требованиям концепта Handler_C. А фабрика сама по себе должна поддерживать вызов operator() с передачей туда лямбды H и возвратом чего-то приводимого к TaskUniquePtr.

Благодаря таким концептам make_task приняла вид:

template<Handler_C H, TaskFactory_C<H> Factory>
void make_task(
   Scheduler & scheduler,
   Factory && factory,
   H && handler)
{
   scheduler.push(factory(std::forward<H>(handler)));
}

В общем, я получил то, что хотел.

Под катом полный код автономного примера, который всю эту кухню демонстрирует.

У меня же пока впечатления от концептов смешанные. Вероятно, вещь хорошая и, местами, полезная. Но вот ВАУ эффекта пока не случилось.

Вообще с C++ есть давняя история: сперва в нем появляется какая-то фича (в смысле описывается в стандарте языка), потом она становится доступна в каком-то из компиляторов, потом у тебя появляется возможность применять ее на практике, потом ты отрефлексируешь свой опыт ее использования и только тогда тебя догоняет понимание того, как же ее нужно использовать, чтобы это было и полезно, и не сложно, и не приводило к проблемам, о которых ты раньше и не знал. С некоторыми фичами эта история растягивается не на один год. Вот как у меня с концептами, например. А до этого похожая история была с noexcept.

четверг, 7 марта 2024 г.

[work.culture] Использование жестких, на грани грубости, оценок в работе

Подавляющую часть своей карьеры я работал в отечественных компаниях (не делаю здесь разницы между компаниями из РБ и РФ). И привык к тому, что в нашей производственной культуре нередко используются грубые оценки свершившегося, происходящего и/или грядущего.

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

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

Так вот о глобализации. Она же сказывается. И, есть ощущение, что традиции толерантности проникают и к нам. И уже даже в русскоязычных коллективах начинают использовать эвфемизмы дабы никого не обидеть. Т.е. там, где в середине 90-х просто бы сказали "код херовый", сейчас уже что-то вроде "возможно, в нескольких местах к качеству есть вопросы".

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

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

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

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

Во-первых, у нас есть ощущение наличия или отсутствия морального права на высказывание оценок. Грубо говоря, если про твой код "херня" говорит человек, который тебя учил программировать или с которым вы прошли не один проект, то это нормально. А когда это делает зеленый новичок или человек со стороны, который пока еще не продемонстрировал понимания предмета, то это чревато конфликтной ситуацией. Иными словами: если вы уже стали "своим" в коллективе, то ваши жесткие высказывания воспринимаются совсем не так, как если вы для коллектива вообще никто, какой-то непонятный хер с горы.

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

Мат (если без него не обойтись) должен использоваться только в исключительных случаях.

И ни в коем случае нельзя использовать мат в отношении подчиненных. Да, я понимаю, что временами очень тяжело удержаться от вопроса "ты что долбо*б?" или "а не ох*ел ли ты?", но нужно. Да и клиентов, в их отсутствие, так же лучше матами не обкладывать. И не только потому что везде есть уши 🙂

Т.е. если мат используется эпизодически и к месту, то это еще терпимо. Хотя лучше бы вообще без... Но жизнь есть жизнь.

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

И да, есть еще и, в-третьих: если был не прав, то это нужно публично признавать. Это еще одна составляющая той самой прямоты и откровенности.


В общем, к чему это я?

К тому, что хотелось бы иметь возможность прямо спросить "почему здесь сделано через жопу?" и услышать в ответ "времени было мало, успели только через жопу". Прямота и откровенность лучше попыток прикрыть горькую правду словесной эквилибристикой.

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

понедельник, 4 марта 2024 г.

[prog.flame] Пример серьезного, на мой взгляд, просчета в API библиотеки libconfig

Впервые довелось столкнуться с библиотекой libconfig. Довольно таки популярной, насколько я могу судить. Тем удивительнее было обнаружить там описанный ниже косяк.

Чтобы получить значение целочисленного параметра нужно использовать функцию config_setting_get_int:

int config_setting_get_int (const config_setting_t * setting)
long long config_setting_get_int64 (const config_setting_t * setting)

These functions return the value of the given setting. If the type of the setting does not match the type requested, a 0 value is returned.

Т.е. если мы пытаемся получить значение параметра, а нам возвращают 0, то этот ноль может означать:

  • что значение не получено, т.к. оно имеет другой тип. Например, вместо my_param=0 задано my_param="0" или my_param="zero";
  • что значение получено и это таки ноль. Просто ноль.

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

Аналогичная проблема есть и с семейством функций config_lookup_int/config_lookup_int64 и прочих вариантов lookup-чего-то-там. Эти функции возвращают CONFIG_FALSE и в случае, если параметр вообще не был найдет, и в случае, если был найден, но содержит не тот тип (например, строка вместо числа).

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

Т.е. получив 0 из config_setting_get_int или CONFIG_FALSE из config_lookup_int у меня нет возможности разобраться с тем есть ли ошибка и, если есть, какая она.

Хотя, как по мне, избежать этой проблемы можно было бы очень просто, если бы у config_setting_get_int был другой формат:

int config_setting_get_int(
  // Где искать значение.
  const config_setting_t * setting,
  // Куда помещать значение.
  int * value_receiver)

И возвращаемое значение означало бы признак успешности, вроде такого: 0 -- все OK, -1 -- значение имеет другой тип, -2 -- значение слишком большое, чтобы уместиться в int и т.д.

Очевидная, вроде бы, вещь. Но почему-то не сделанная... 🙁

суббота, 2 марта 2024 г.

[life.cinema] Очередной кинообзор (2024/02)

Подошло время очередного отчета о просмотренных за минувший месяц фильмах. Традиционно в начале каждого из списков идет то, что понравилось больше, а в конце -- то, на что можно не тратить свое время. Сразу спойлер: ничего реально достойного нет (фильм "Звук свободы" идет своей отдельной категорией) :(

Фильмы

Пчеловод (The Beekeeper, 2024). В описании фильма забыли написать, что это фантастический боевик :) Иногда, чтобы подчеркнуть чью-то крутизну, говорят, что "круче только горы и вареные яйца". Но нет таких гор, которые были бы круче, чем герой Стейтема в этом фильме :))) Посмотреть вполне можно, экшОн снят бодренько. Но ни в коем случае нельзя относиться к происходящему на экране всерьез.

Плохие парни (Bad Hombres, 2023). Откровенно бюджетный фильм, но за развитием событий в котором интересно следить. Могло бы получиться на удивление неплохо если бы откровенно унылым финалом авторы не спустили все свои предыдущие усилия в унитаз.

Территория зла (Land of Bad, 2024). Очень двойственные впечатления: с одной стороны картинка шикарная. Но с другой постоянно преследует ощущение "нам втирают какую-то дичь". По итогу ощущения негативные, можно пройти мимо этого фильма.

Сериалы

Месье Спейд (Monsieur Spade, первый сезон, 2024). Очень красиво снято. И Клайв Оуэн, как по мне, отлично воплотил образ частного сыщика из детективов Дешела Хэммита. Но в целом сериал оставил посредственные впечатления. Слишком много внезапных флешбэков, слишком много розовых соплей, слишком неспешное повествование на протяжении всего сериала, но при этом очень скомканные и кульминация с развякой (толком не понимаешь что же именно и из-за чего произошло).

Кошка (первый сезон, 2023). Ну такое себе. Мне гораздо интереснее было смотреть на виды Калининграда, в котором события развивались, чем за самими событиями. Так что не советую.

Джек Ричер (Reacher, второй сезон, 2023). Первые семь серий были вполне себе в духе первого сезона. Но в последней серии авторы устроили такую муть, что убили вообще все положительные впечатления. В итоге сильно не понравилось.

Иные (первый сезон, 2023). Похоже, что все деньги и усилия были вложены в визуальную составляющую. Эта часть, действительно, выглядит достойно. Чего не скажешь о всем остальном. Так что можно смело проходить мимо.

Настоящий детектив (True Detective, четвертный сезон, 2024). Это не детектив, а редкой нудятины муть про страдания "сильных и независимых женщин" (c), да еще и чрезмерно сдобренная мистикой. Категорически не рекомендую.

Фильм вне категории

Звук свободы (Sound of Freedom, 2023). История, рассказанная в фильме поражает и, конечно же, не может оставить равнодушным. Но вот сам фильм, именно как фильм, мне показался откровенно слабыми и не раскрывающим весь ужас данной темы. Поэтому как-то оценивать его в целом не берусь.

четверг, 29 февраля 2024 г.

[prog.c++.flame] Вот пример того, что мне сильно не нравится в C++

Попалась мне несколько дней назад старая статья на тему хитрых трюков в C++: "NON-CONSTANT CONSTANT-EXPRESSIONS IN C++". Да, я знаю, статья не новая, но вот такой я медленный газ... 🙁

В статье показывается некий трюк, который типа должен позволить сделать так:

int main () {
  constexpr int a = f ();
  constexpr int b = f ();

  static_assert (a != b, "fail");
}

Т.е. два вызова одной и той же constexpr-функции в compile-time должны дать разный результат.

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

Посему захотелось написать о том, как же я не люблю эту сторону C++, когда люди находят хитрую лазейку в правилах языка и вместо того, чтобы сказать "вот здесь есть лазейка, которая может привести к неожиданным результатам, будьте осторожны, а еще лучше давайте придумаем как ее исправить и/или вообще устранить", они начинают писать статьи о том, как эту лазейку можно эксплуатировать.

Хочется привести такую аналогию: возможно вы замечали, что временами при вставке штепселя в электрическую розетку возникает искра (и при извлечении такое случается). Иногда искр меньше, иногда больше. Насколько я знаю, этот эффект физиками давно изучен и он лежит в основе электроискровой резки металлов. Ну да не суть. Суть в том, что вот кто-то обнаружил этот эффект и начал экспериментировать: штепсели с разным диаметром контактов, скорость вставки/извлечения, угол под которым все это выполняется и т.д. А потом написал статью, что типа если вы будете делать вот так, то у вас гарантированно будут искры и искры эти будут наиболее яркими. А потом кто-нибудь еще и попытается извлечь какую-нибудь "пользу" из такого "открытия"?

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

Тогда как в мире C++ вот такое вот "открытие" привлекает внимание и ведет к продолжению подобных поисков и экспериментов. И это считается нормальным. Более того, такие вещи обсуждаются. Возможно, кто-то даже решается применить что-то подобное в своей работе. А потом это вырастет в какую-то продвинутую технику программирования на C++, по типу случайно открытого в середине 1990-х метапрограммирования не шаблонах...

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

Но самый цимес обнаружился при попытке проверить представленное в статье решение 😅

Оказалось, что само решение (в коротком его варианте), работает только в VC++.

Под GCC работает только то длинное решение, которое типа должно обходить баг в clang-е. А вот короткое решение не работает.

Тогда как в clang-е не работает вообще ни короткое, ни длинное.

Ну и, спрашивается, нахера весь этот огород было городить, если он "работает" только на одном из компиляторов? Так и хочется вспомнить старое, но актуальное: "Не выёбывай умничай" 🤬

Павбывавбы.

понедельник, 12 февраля 2024 г.

[prog.multithreading.bugs] Повезло столкнуться с собственным багом в многопоточном коде

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

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

Но, как оказалось, не всегда это выполнялось правильно. Даже не смотря на наличие тестов 🙁

Особо доставили два момента:

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

Во-вторых, осознание того, что я не помню последовательности запросов, которая привела к такой ситуации. Я их накидывал случайно, в разном порядке, с разными параметрами. И когда заметил подозрительные следы в отладочных печатях, то не смог вспомнить в каком именно порядке какие запросы выдавались.

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

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

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

Оказалось вот что: у меня был ассоциативный контейнер (std::map), содержимое которого защищалось мутексом. Но в одной из веток происходило следующее:

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

По недосмотру в коде не оказалось повторного захвата мутекса после того, как текущая нить дождалась своего события и проснулась. Поэтому обновление контейнера было уже не thread-safe 🥴

Поэтому получилось, как я полагаю, следующее: на ожидании своих событий заснуло две нити. Потом они проснулись практически одновременно одна за одной (вот такое вот счастливое для меня стечение обстоятельств) и сперва первая добавила свою информацию в этот контейнер, а потом и вторая. Причем, подозреваю, обновление контейнера происходило чуть ли не одновременно. Т.к. стечение обстоятельств было действительно счастливым, то изначально контейнер был пустым и сперва одна нить туда добавила свою информацию, а затем и вторая. При этом, как я подозреваю, вторая нить просто полностью перезаписала содержимое контейнера. Из-за чего в нем оказался один-единственный элемент, а не два. А уже это привело к нарушению всей последующей логики обработки очереди запросов и в работу ушли даже те запросы, которые должны были еще подождать.

В общем, целый ряд счастливых случайностей:

  • сперва я очень удачно сгенерировал "правильную" последовательность запросов которая привела к тому, что две рабочие нити проснулись в одно время;
  • затем повезло с тем, что при перезаписи std::map-а из разных потоков не образовался какой-то мусор из-за чего бы программа могла бы упасть с segmentation fault;
  • и все это случилось когда в программе еще оставались отладочные печати, благодаря которым на консоль сбрасывались дампы с информацией о текущих запросах;
  • ну и каким-то чудом в этих самых дампах я заметил то, что у ряда запросов статус оказался "в работе", а не "в ожидании".

Короче говоря, без везения в поиске багов в многопоточке не обойтись 😎

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

Сам я себя ни в коем случае специалистом по многопоточному программированию не считаю, мне тупо не хватает мозгов, чтобы моделировать все то многообразие сочетаний событий, которое может возникнуть в многопоточном коде. Я поэтому-то SObjectizer-ом и занимаюсь, чтобы свести работу с многопоточностью к минимуму. Поэтому в моем многопоточном коде баги были, есть и будут. Куда же без них 😉 Главное, чтобы они вовремя наружу вылазили, под присмотром 🤣


На правах саморекламы: изобретаю велосипеды для себя, могу изобретать и для вас.