Pull to refresh

Comments 108

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

абсолютное непонимание зачем нужен atomic shared ptr

Деление на Rc и Arc вместо одного shared_ptr (кстати сравните понятность названий) это типичный антипаттерн, причём выигрыш в производительности невероятно мал (практически 0), в целом shared ptr в однопоточной системе это крайне редкое явление.

Это провоцирует дублирование кода (в бинарнике в том числе), дополнительные сложности с написанием кода и особенно его развитием - например как в статье человек просто добавил ещё один поток, казалось бы ничего не изменилось, но ему пришлось вносить правки в множество мест в коде

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

P.S. а покажите как в вашем shared ptr сделать кастомный делитер. А никак, раст не смог...

кстати сравните понятность названий

однажды прочитав reference count и atomic reference count, проблем с понятностью больше не возникает. Длинные названия как минимум неудобно, учитывая, что часто они оборачиваются во что-нибудь ещё (RefCell, который ещё и внутри Option, например).

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

вы видимо не сталкивались с проблемами гейминга/HFT. Ну и в случае с Rc он фактически нужен вместо обычных указателей ибо на обычные указатели распространяется немало ограничений касающиеся времени жизни и заимствования. Однопоточная версия отлично оптимизуется компилятором без потери безопасности.

Некоторые вещи в Rust иногда действительно "страдают" наличием специализированных имплементаций для отдельных типов (как в случае с имлементацией Sync/Send для Arc или отдельных map для Option и Iter), но более генерализованные решения либо были слишком unsound с диким временем компиляции, либо оказывали влияние на общую производительность из-за отключения оптимизаций - никто особо не хочет С+++.

а покажите как в вашем shared ptr сделать кастомный делитер. А никак, раст не смог...

реализовать трейт Drop для вашего объекта. Можно запариться ещё и с кастомными аллокаторами.

Ну и в случае с Rc он фактически нужен вместо обычных указателей ибо на обычные указатели распространяется немало ограничений касающиеся времени жизни и заимствования

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

однажды прочитав reference count и atomic reference count, проблем с понятностью больше не возникает

возникает, в отличие от С++, где shared ptr передаёт семантику, в расте название это аббревиатура от реализации, зачем выставлять реализацию наружу - неизвестно

 учитывая, что часто они оборачиваются во что-нибудь ещё (RefCell, который ещё и внутри Option, например)

это тоже проблема раста, именно его дизайн такое провоцирует

реализовать трейт Drop для вашего объекта

нет, это требует другого типа. И да, опять же это дизайн раста - нужно сравнить типы по другому? Делайте новый тип и перекладывайте все объекты в другой контейнер

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

нет, это требует другого типа.

Только если надо реализовать Drop для типа из другого крейта.
Имхо очень правильное архитектурное решение, оно гарантирует, что реализация типа сосредоточена в 1 крейте. А тип, который по-другому делает Drop, и называться должен по-другому.

Надо удалить как то по другому тип? Ну пишите новый тип, а ещё перегружайте оператор точка.

При чём здесь перегрузка точки в вопросе удаления объекта?

А тип, который по-другому делает Drop, и называться должен по-другому.

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

Я вот на Rust не пишу, а вот на С++ пишу. И всё же у меня всегда возникает вопрос: а оно мне точно надо, этот кастомный deleter? Почти всегда это костыль.

Это удобно, когда в shared_ptr заворачивается какой-то посторонний ресурс: указатель из мира C, хендлер со своей логикой, токен какой-нибудь который надо вернуть в пул и т. п.

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

Ну так Drop фактически и есть функция деструктора, только вам дополнительно обёртки писать нет необходимости т.к. функция к вашему типу привязывается и будет работать ровно так же как кастомный deleter. Как вариант можно ручками в шаблон типа его указать, а не привязывать его непосредственно в Rc как это делает shared_ptr. Заодно и боли с синхронизацией меньше будет, если оно Arc станет.

из мира C

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

вы не понимаете видимо о чём речь, у типа уже есть какой то деструктор, а вы предлагаете сделать ещё один тип и ещё один деструктор. Ещё один тип означает создание ещё бесконечного множества инстанцирований шаблонов с ним, ну и например вы захотели изменить логику - не просто удалять, а ещё логгировать. Всё, вам нужно переписать весь код который использует этот Rc/ Arc, т.к. он теперь указывает на другой тип

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

let rc = make_shared!(MyType, |t|{ t.log(); });

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

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

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

Это действительно так, но идеологически - это более правильно.

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

 но идеологически - это более правильно.

нет, потому что у вас возникает фейковый тип, который ни для чего не нужен кроме деструктора (который ещё и семантически делает не разрушение)

Если ваша функция делает нечто настолько отличное от разрушения объекта, что её ну никак нельзя считать деструктором - то и передавать в shared_ptr/Rc/Arc её никак нельзя. Ведь deleter-то вызывается из деструктора указателя!

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

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

Мне - ничего.

Это только если вы можете гарантировать что ваше "сообщение о том что операция завершена" никогда не выбросит исключение. В принципе, даже std::cout может бросить исключение если ему разрешить: cout.exceptions(iostate) .

В этому по сути фундаментальное различие - Rust дает сильно больше гарантий. Если ваш код уже скомпилировался, то вероятность того что в нем что-то неожиданно пойдет не так гораздо ниже чем в C++. С++ считает что программист не делает ошибок и что любая программа не содержит UB. Вот только я гарантирую что я найду UB в любом более-менее большом проекте на C++.

Это только если вы можете гарантировать

во-первых, я могу такое гарантировать ( noexcept ), в расте насколько я знаю аналога этому нет. Во вторых, даже если я гарантировал, в расте всё ещё нет возможности это сделать

В третьих, даже если исключение вылетит, ничего "опасного" (как называют его растеры) не произойдёт, максимум std::terminate

Но, ведь...

Это довольно странный и даже строго говоря некорректный аргумент и вот почему

  1. Кастомный deleter является частью типа умного указателя в С++. Соответственно формально мусорный тип мы всё равно порождаем.

  2. Более того, сам Deleter - это тоже не какая-то функция, это "функтор", т.е. по сути структура с переопределённым оператором скобочек. Т.е. придётся если подходить к вопросу совсем дубово, то придётся породить целых два новых мусорных типа.

  3. Даже если вы не согласны с пунктами выше вы всё равно породите мусорный тип через using просто для удобства.

Вы же не будете каждый раз писать что-то такое:

Код на С++17
// Где-то приходится написать мусорный 
// Deduction Guide 
namespace std 
{
    template<typename P, typename D>
    unique_ptr(P*, D&&) -> unique_ptr<P, D>;
}


// одна единица трасляции
auto ptr1 = std::unique_ptr(&s, [](session* s){ close_session(s); });

// другая единица трасляции
auto ptr2 = std::unique_ptr(&s, [](session* s){ close_session(s); }); 

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

Код на версиях ниже С++17 еще страшнее
// Можно так
auto deleter = [](session* s){ close_session(s); };
auto ptr1 = std::unique_ptr<session, decltype(deleter)>(&s);

// Можно совсем пойти страшным путём
// но я бы такое никогда на ревью не пропустил
auto ptr2 = std::unique_ptr<session, decltype([](session* s){ close_session(s); })>(&s);

Вы скорее всего, удобства ради, напишете вот такое

Работает с любым стандартом
// Упс, мусорный тип
using session_ptr = std::unique_ptr<session, decltype([](session* s){ close_session(s); })>;

// Зато какой удобный!
auto ptr = session_ptr(&s);

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

Ну и да, кстати в Rust написать обвязу вокруг Box, если нужен такой функционал - дело недолгое, но на мой взгляд вообще не нужное.

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

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

Я и на плюсах и на расте пишу, кастомный делетер в шаред поинтере нужен совсем для других целей никто конечно руки выламывать не будет, хотите использовать по другому вперёд. Кастомный делетер нужен для того чтобы отдать шаред поинтер на дочерний объект но при этом мы все же хотим что бы удалилось все дерево объектов, а не только дочерний (и то он скорее всего владеется по значению). Короче хотите понять зачем шаред поинтер такой какой есть смотрите алиасинг перегрузку конструктора. Я 1 раз этим пользовался очень нишевая функциональность.

Не мешайте человеку хотеть, он художник он так хочет.

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

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

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

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

Если у вас "shared_pointer" владеет объектом, то когда кончается pointer, кончается и его объект. Тут Drop будет вызван. А если не владеет — то у вас что в нём лежит? &mut T ? Или другой аналог указателя, которому оторвали руки, чтобы он ими не лез никуда. Что вы собрались деструктировать в указателе?

Никто же не жалуется, что в С++ нет метода ~int() хотя если б был, я бы ему уж придумал применение.

Никто же не жалуется, что в С++ нет метода ~int() хотя если б был, я бы ему уж придумал применение.

Приятного просмотра:

using T = int;
int a = 10;
a.~T();

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

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

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

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

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

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

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

Ладно, я имел в виду все же через ссылки. Потому что иначе BC вообще не при делах.

Деревья и графы прекрасно делаются на Rc/Weak. Вот со списками есть проблемы...

Но список - это же граф?

Не с прикладной точки зрения.

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

Т.е. "Деревья и графы прекрасно делаются на Rc/Weak" надо понимать как то, что они прекрасно делаются на Rc/Weak и готовой реализации списков, поэтому высказывание не применимо к спискам?

Ну, в общем-то да. Хотя зачастую можно обойтись Vec.

в отличие от С++, где shared ptr передаёт семантику, в расте название это аббревиатура от реализации

После того, как в C++ получилось, что между std::function и std::copyable_function разница совсем не в том, что один тип копируемый, а другой -- нет, я как-то перестал пинать другие языки за неочевидные наименования.

не существует copyable function

Ничего не знаю, у нас в 2026 все есть.

я говорю - этого не существует, С++26 не принят

Что, думаете одумаются и таки назовут std::function2?

Ну так-то std::copyable_function является не то чтобы альтернативой, а скорее дальнейшим развитием std::function, например, она умеет в small object optimization. А назвали её так "в пику" std::move_only_function, так что определённая логика тут есть. Не называть же её std::function_2_0, хотя по факту она ей и является.

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

Помимо SSO, там еще есть поддержка специализаций const/&/&&. В целом std::copyable_function -- это исправленный std::function. В определенной мере было бы даже логичнее назвать новый тип std::function2 и не морочить людям голову.

Или как минимум сразу же задепрекейтить старый std::function, но судя по std::thread и std::jthread этого не будет.

в std::function всегда было SSO, а прописывать его в стандарте вообще бессмысленно

"Не называть же её std::function_2_0"
Почему же? Oracle же например назвал аж целый тип как VARCHAR2

copyable_function- непринятый red herring. А вообще вполне понятное назваение если понимашь что это контейнер функциональных объектов, который может быть пустым. Объекты могут быть некомипуемыми или только перемещаемыми

То, что он пока еще непринятый -- это софистика. std::jthread приняли, оставив std::thread как есть, и этот примут.

Если бы были только типы std::copyable_function и std::move_only_function, то все было бы в порядке. Но получается так, что в стандартной библиотеке на равных правах сосуществуют три типа:

  • std::function

  • std::copyable_function

  • std::move_only_function

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

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

Со мной это случилось после появление std::iota.

Когда я это увидел впервые на какой-то конференции, я такой: "да вы с ума все посходили". Причём на википедии даже не описан такой смысл у этой буквы в греческом.

Ответ на SO по этому поводу, привёл меня к мысли, что все реально с ума посходили)

реализовать трейт Drop для вашего объекта

Даже для не совсем тривиальных ситуаций, когда ресурс просто "получили-удалили"? Условно, можно что-то такое сделать? И если да, то насколько просто в сравнении с плюсами?

Hidden text
#include <memory>
#include <thread>

struct resource
{
    struct session { };

    session *create_session() {
        auto ret = new session{};
        // for example register_session(ret);
        return ret;
    }

    void close_session(session *s) {
        // deregister_session(s);
        delete s;
    }
};

resource *get_resource() {
    return new resource{};
}

void free_resource(resource *r) {
    delete r;
}

namespace std {
    template<typename P, typename D>
    shared_ptr(P *,D) -> shared_ptr<P>;
}

int main(void)
{
    std::jthread t;
    std::jthread t1([&t]{
        auto res = std::shared_ptr(get_resource(),[](auto p){free_resource(p);});
        std::jthread t2([s = std::shared_ptr(res->create_session(), [res](auto p){res->close_session(p);})]{
            std::this_thread::sleep_for(std::chrono::seconds(5)); // work
        });
        // work;
        t = std::move(t2);
    });
}

P.S. опустим вопросы притянутости (относительно менеджмента потоков, не владения) конкретно этого примера

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

struct Resource;
impl Resource {
  fn new() -> Self {
    return Resource{}
  }
  // тут вернул указатель, а не данные напрямую
  fn create_session(&self, ...) -> Rc<Session> {
    return Rc::new(Session::new(...));
  }
}
impl Drop for Resource { fn drop(&mut self) { todo!(); } }

struct Session{...};

impl Session {
  fn new() -> Self {
    return Session{...}
  }

  fn close_session(&mut self)  {
    todo!();
  }
}
impl Drop for Session { 
  fn drop(&mut self) { 
    self.close_session();
  }
} 

Пример на аналогичный, тут close_session часть (член, метод, не знаком с терминологией раста) session, а не часть resource. Ну и непосредственно наполнения main'а тоже нет.

который не хранит данных

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

 session, а не часть resource

Принцип Барбары Лисков подсказывает, что методы сессии должны быть связаны с сессией, а не с кем-то сторонним. Ну и формирование архитектуры должно учитывать, что по-умолчанию в ходу механизм RAII, для чего-то специфичного - используйте кастомные аллокаторы и арены и добавляйте какие угодно дополнительные методы к ним. Однако это прямой путь к неподдерживаемой лапше и экосистема будет противиться подобным трюкам.

Вот так плавно съехали с обсуждения возможности выразить определённые вещи (с примером, иллюстрирующим эти вещи, а не пропагандирующим какой-либо подход к чему-либо) на обсуждение того, что так не надо, так не нужно, делайте иначе. Прям до боли напоминает типичные русскоязычные форумы, на которых на вопрос "как сделать А" отвечают "зачем тебе делать А?"/"А никто не делает, делай Б".

Если хотите стрелять в ногу - используйте cve-rs или заверните весь блок в unsafe. Если хочется писать лапшу как на плюсах - тоже без проблем, но со страданиями. Я не фанат страданий и не планировал заниматься ненормальным программированием, поэтому написал MVP, которое примерно соответствует исходному интерфейсу. Можете даже в ООП стиле как в джаве или С# писать, если очень хочется. Но пишущие на том же языке с большой долей вероятности покрутят у виска на такие извращения. Для остальных - пусть изучают датаориентированный подход.

Ну и раз есть какие-то конкретные требования - составляйте тз подробне.

P.S. а покажите как в вашем shared ptr сделать кастомный делитер. А никак, раст не смог...

Используюя типичный растовый паттерн:

use std::sync::Arc;

#[derive(Debug)]
struct My {
    data: u8,
}

#[derive(Debug)]
struct Wrapper {
    rc: Arc<My>,
}

impl Wrapper {
    fn new(data: u8) -> Self {
        Self {
            rc: Arc::new(My { data }),
        }
    }
}

impl Drop for Wrapper {
    fn drop(&mut self) {
        println!("Dropping Arc...");
    }
}

impl Clone for Wrapper {
    fn clone(&self) -> Self {
        Self {
            rc: self.rc.clone(),
        }
    }
}

fn consume(a: Wrapper) {}

fn main() {
    let my = Wrapper::new(0);
    consume(my.clone());
    println!("My: {my:?}");
}
Dropping Arc...
My: Wrapper { rc: My { data: 0 } }
Dropping Arc...

Да не настолько всё плохо. Если выкинуть из кода те части, которые не относятся к задаче "сделать кастомный делитер", и немного упростить остальные, то получится как-то так:

struct MyWrapper(My);

impl Drop for Wrapper {
    fn drop(&mut self) {
        println!("Dropping Wrapper…");
    }
}

Да, это многословнее чем на плюсах, но не сильно.

Всё-таки обёртку надо делать вокруг ресурса, а не указателя.

Ради интереса сделал простенький бенчмарк между Rc и Arc. Разница почти в 3 раза. Да, в большинстве случаев это не важно, но лучше иметь выбор, чем не иметь кмк.

testing regular_counter,                               min time =     0 microseconds
testing indirect_counter,                              min time =   338 microseconds
testing boxed_counter,                                 min time =  2280 microseconds
testing boxed_indirect_counter,                        min time =  2137 microseconds
testing atomic_counter,                                min time =  5401 microseconds
testing rc_counter,                                    min time =  4620 microseconds
testing arc_counter,                                   min time = 11031 microseconds

Это не бенчмарк между Rc и Arc, а какая то бессмыслица. Просто увеличение в цикле штучки на 1, иногда просто прибавляя, иногда атомарно прибавляя, иногда вызывая функцию возвращающую 1

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

Вероятно вы не понимаете во что данный код компилятором оптимизируется

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

playground::regular_counter:
	mov	eax, 1000000
	ret

regular_counter как раз так и оптимизируется.

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

Согласен. Но я на это и не претендовал. Это просто 1кк клонов с какой-то самой минимальной работой для того, чтобы её как раз нельзя было в 0 соптимизировать. И как я же и сказал, это не имеет значения в большинстве случаев, но 1) разница в скорости от этого никуда не денется; 2) возможность выбрать лучше, чем её отсутствие. Потому что иногда эта разница очень даже может иметь значение.

Edit: "Результаты" без black_box:

testing regular_counter,                               min time =     0 microseconds
testing indirect_counter,                              min time =     0 microseconds
testing boxed_counter,                                 min time =     0 microseconds
testing boxed_indirect_counter,                        min time =     0 microseconds
testing atomic_counter,                                min time =  5406 microseconds
testing rc_counter,                                    min time =     0 microseconds
testing arc_counter,                                   min time = 11036 microseconds

возможность выбрать лучше, чем её отсутствие. 

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

тогда где шаред поинтер с кастомным аллокатором?

В смысле - где? А что, по-вашему, означает второй параметр типа у Rc и Arc?

Где с кастомным делитером?

Делается за 5 минут если действительно нужен.

Где возможность аллоцировать в одном месте само значение и управляющий блок?

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

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

А что, кто-то утверждал обратное?

А что, по-вашему, означает второй параметр типа у Rc и Arc?

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

Стирание типа в этом месте (как сделано в С++) наиболее логичное решение

То есть вам нужен Arc<T, Box<dyn Allocator>>, по факту?

в целом shared ptr в однопоточной системе это крайне редкое явление.

Если не shared_ptr, то какой тип указателей вы предлагаете использовать в однопоточных приложениях?

в целом shared ptr в однопоточной системе это крайне редкое явление.

У вас есть какие-то пруфлинки этому утверждению? Желательно с ответом на вопрос "почему не используют". А то может окажется что вы переставили местами причину со следствием, и shared_ptr не используют в однопотоке именно из-за его оверхеда.

А зачем он нужен в однопоточном приложении, если все объекты можно, в целом, создать на стеке этого одного потока и использовать только reference?

Ну вот зачем-то же в раст добавили Rc.

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

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

Мы же про shared ownership говорим, а не про динамическую память.

Как только появляется динамическая память, возникает вопрос как её менеджить и когда освобождать. Один из вариантов ответа на этот вопрос - shared ptr.

Если мы говорим про однопоточное приложение, то чтобы менеджить динамическую память shared_ptr не нужен. Динамическая память обычно менеджится классами-обертками (контейнер/буфер) и освобождается при уничтожении таких объектов в деструкторе. shared_ptr такую динамическую память напрямую не менеджит, он просто контролирует количество ссылок (копий shared_ptr) и вызывает деструктор какого-то объекта only once, который и освободит эту память. В однопоточном приложении объекты попросту не с кем (чем) расшаривать и вместо shared_ptr можно просто создавать объекты на стеке это треда или если уж очень хочется ptr, то можно использовать unique_ptr. Как только такой объект покинет скоуп он уничтожится автоматически почистив за собой всю динамически аллоцированную память.

Вы где предлагаете его хранить то? В глобальных переменных? Менеджер сделать(контейнер, буффер), который будет, по сути, такой же глобальной переменной?
Как только у нас появляется система, где нет иерархии уровня мастер-слейв, появляется необходимость в shared_ptr или аналоге, потому что нет точки в которой можно сказать: "здесь объект можно удалить", кроме точки "им больше никто не пользуется".

Да создавать просто все объекты на стеке main-треда как локальные переменные и использовать потом их reference. У вас же один тред в однопоточном приложении и нет других тредов с кем объекты можно шарить.

Есть объект А, он использует объект X, также в неопределенный момент может создаться объект B, который тоже использует X. А и B друг другом не владеют и могут умирать независимо друг от друга(например А - сетевой обработчик, который привязан к клиенту которые могут подключаться и отключаться, а B - визуализация коннекта, которую могут видеть, могут не видеть и надо иметь её даже если клиент уже отключился).
Каким образом и где вы предлагаете создавать объект Х?

А теперь представим систему без поддержки атомиков (да, такие существуют). С Раст понятно - Rc, а как на плюсах?

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

Нет, меня все устраивает: не пишу на плюсах )

А можно раст юзать чтоб писать с winapi?

Либо исходный текст отвратный, либо перевод кривой.

Читать невозможно, повествования нет.

Начал с середины, закончил тоже в середине измышлений.

Ошибка пошла с этой фразы

Работая над внутренней библиотекой, написанной на Rust, я создал тип ошибок для парсера, у которых должна быть возможность сделать Clone без дублирования внутренних данных. В Rust для этого требуется указатель с подсчётом ссылок (reference-counted pointer) наподобие Rc.

А кто сказал, что поиск точно такого же решения на С++ является хорошей идеей? Основная же задача была "сделать Clone без дублирования внутренних данных", а не атомарный или нет подсчёт ссылок.

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

Странная какая-то статья. В мире C/C++ давным давно успешно решены вопросы парсинга без необходимости аллокации/копирования памяти. См. gumbo (парсинг HTML), faxpp (парсинг XML) и т.д.

Но вот для чего может потребоваться именно многопоточный парсинг - это вообще загадка. Терабайты JSONов парсить? Так современные SIMD парсеры это и так умеют делать в один поток со скоростью линейного чтения данных из памяти (и даже быстрее, что почти парадоксально).

А вообще reference counting, равно как и garbage collection - это путь в никуда (вместо них есть obstacks, arena memory regions). Как впрочем и весь Modern C++ c их RAII (тоже фундаментально поломано на старте, но это тема отдельной большой статьи).

Как впрочем и весь Modern C++ c их RAII (тоже фундаментально поломано на старте, но это тема отдельной большой статьи).

Можно пояснить или указать, где почитать об этом?

RAII появился до Си++, собсвтенно эта концепция в Аде и породила Си++, а для него был описан в 80х годах еще.

Уберите RAII и у вас получится пальцем деланный Objective-C

Как это объясняет поломанность modern C++ или самого RAII?

Modern C++ фундаментально сломан просто способом реализации RAII.

move semantics, perfect forwarding, auto &&, prvalue, xvalue, glvalue и прочие костыли с подпорками. Это все не сильно приятно читать, оно выглядит слишком уродливо, слишком "не так, как у всех".

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

Как-то совсем не понял, зачем это может понадобится.

Move-semantics это вообще относительно "новый концепт" который не существует в других языках из-за их языковой модели и в другом месте бы "не взлетел".

Можете накидать примерно, зачем нужно перегружать ',', если перегрузок стрелки я как-то не видел за свою практику на C++17 (C++20 фичами пока не пользовался).

В Rust он существует и сделан таки нормально

Добавили, теперь считается антипаттерном

В Расте то? Нет, там мув точно не антипаттерн. Насчёт плюсов не знаю.

А разве shared_ptr и unique_ptr реализованы не за счет перегрузки оператора -> ?

Позволить перегружать оператор точку нужно примерно для этого-же, а также с целью реализовать нормальный ссылочный тип с возможностью Fast-PIMPL, интерфейсы (ISomething), да много еще чего. К примеру ссылочные типы (указатели) с опциональным запретом ссылаться на NULL.

Ну и самую малость - вообще избавиться от оператора стрелки -> в коде. Во всех остальных языках (которые C/С++ подобные) оператор точка является оператором разыменования ссылочного типа, по сути указателя, и только в C++ зачем-то до сих пор нужно нужно обязательно и без вариантов всегда писать через K&R C рудиментарную форму object->call() вместо object.call()

А хотелось бы как у всех. Мелочь, но почему нет?

Тем более сами ссылки в C++ сделаны изначально криво (которые Object &obj). Они по факту ничто иное как обычные указатели с альтернативным синтаксисом, но вот уже который десяток лет все делают умный вид и утверждают что это не так, что это не совсем указатели и вообще в будущем там какой-то дополнительный новый смысл появится. А по факту лишь очередная досадная "не как у всех таковость".

"уродливо" слабенький аргумент в пользу "фундаментально сломан"

C++ развивался и развивается эволюцией. Просто заново сделать не получится, нужна обратная совместимость.

Сейчас есть несколько проектов языков преемников с нативной поддержкой C++, например Carbon и Cpp2, но они скорее в стадии исследования дизайна.

C++ развивался и развивается эволюцией. Просто заново сделать не получится, нужна обратная совместимость.

Почему нельзя? Можно. Можно взять и на старте с плеча рубануть к примеру вот так

#define enum enum class
#define var decltype(auto)
#define let auto
#define in : // for (var item in list)
#define super __super
#define self (*this)
#define extends : public
#define implements , public
#define final static constexpr const auto

И так еще много разных вещей можно сделать без потери доступа к C библиотекам и VS Code плюшкам :)

Но многие штуки пока нельзя. К примеру красиво

#define property

сделать пока не получится. Можно только некрасиво (clang так может, gcc нет, ну и в топку его).

#define property(...) ____VA_CHOOSE(____PROPERTY, VA_ARGS)
#define ____PROPERTY_1(getter) __declspec(property(get=getter))
#define ____PROPERTY_2(getter, setter) __declspec(property(get=getter, put=setter))

Да, при таком подходе можно сразу забыть про STL и Boost, но и не сильно жалко, т.к. эти штуки тоже изначально фундаментально сломаны и лучше не становятся (библиотечный код должен лежать в .a и .so файлах, а не статически inplace копироваться каждый раз по месту, раздувая результирующие бинарники до 100+ мегабайт), но это совсем отдельная и минусуемо крамольная тема.

Carbon и Cpp2,

Подобное никогда уже не взлетит только по той простой причине, что допиливать VSCode, gdb, lldb и прочие clangd для code complete там уже никто не станет, нет критической массы пользователей и финансирование тоже не выбить на это. Общий тренд - если вам эстетически не нравятся синтаксические/библиотечные костыли и подпорки в C++, то идите в Java, Kotlin, Swift или Rust и не нудите тут, не видите, люди в комитетах делом заняты.

библиотечный код должен лежать в .a и .so файлах, а не статически inplace копироваться каждый раз по месту, раздувая результирующие бинарники до 100+ мегабайт

идите в Rust

Да, главное правильно выбрать направление куда идти.

А вообще reference counting, равно как и garbage collection - это путь в никуда (вместо них есть obstacks, arena memory regions)

А почему тот же obstacks это путь куда-то причем вместо reference counting/garbage collection? У нас появляется дополнительный слой в виде чанков/регионов/obstackов. Теперь мы должны решать какие чанки и когда аллоцировать, какие объекты в какие чанки класть, в каком порядке класть, вызывать правильные функции типа grow/fastgrow и т.п. Да, перфоманс аллокаций/деаллокаций при таком подходе безусловно лучше, но memory management становится еще более сложным.

Если перфоманс аллокаций/деаллокаций для нас принципиально критичен, то такой подход безусловно имеет смысл. А если мы хотим упростить себе memory management и чтобы память лишний раз не утекала, то особого смысла в obstacks я не вижу.

Современная индустрия решает задачи "в целом". Т.е. пытается продать управление памятью, которое универсально подходит под все задачи. А это противоествественно, т.е. не отражает фундаментальные принципы работы ПО. А получается плохо для обоих типовых случаев времени жизни объектов (см. ниже).

Если в двух словах - то есть понятие краткосрочного контекста, назовем его условно service method call - т.е. что-то извне дергает наш микросервис, страничку там получить или JSON отдать. В этом случае нам нужно за доли секунды что-то сделать и вернуться в исходное состояние, говоря проще деаллоцировать obstack/memory region, локально-контекстно отросший во время обработки вот этого конкретного внешнего вызова.

И долгосрочный контекст - когда аллоцированная память живет не то что секундами, а может и часами и даже сутками (какие конфиги в микросервисе, аллоцированные на старте или кнопочки-поля ввода для какого приложения GUI).

Третьих вариантов времени жизни объектов я вот не припомню, если честно. Межпросессный message passing какой - там все на предалоцировананных каналах действует... ну и т.д.

Так вот - reference counting/garbage collection на самом деле не нужен ни в первом, ни во втором случае. Если память краткосрочная, то можно просто аллоцировать от души, просто сдвигая HWM, и потом в конце вызова сверхбыстро деаллоцировать весь контекст (регионы), просто выставив HWM в ноль. Не надо натужно там бродить по сотням объектов, ссылки высчитывать или счетчики проверять, напрягая процессор, шину и память рандомным натужным чтением оной. Обходить деревья и дергать деструкторы (RAII) тоже не нужно. Просто хлобысь - ноль в ячейку памяти на 64 бита записали, и красота, все почтистилось. Ну может быть еще madvise() вызвать какой, чтоб страницы памяти в OS отдать.

А, ну... да еще внешние ресурсы типа файлхендлов надо позакрывать в конце вызова, но это отдельная тема управления аллоцированными внешними ресурсами. Но тоже тривиально - в конце вызова метода сервиса (когда response уже улетел клиенту) идем по какому arraylist и делаем close() вызовы или возврат в пулы. Деструкторы там тоже не нужны для этого. Кстати Java примерно так и работает, там краткосрочная куча часто чистится уже в конце внешнего вызова сервис-метода, и внешние хендлы тоже освобождаются не моментально. Именно поэтому Java в мире веб сервисов прямо топчик, там прям все изначально гениально (или просто случайно) заточено под этот типовой веб сценарий.

А вот для долгосрочных объектов работает банальная parent-child древовидная модель (ну... ок, можно сказать RAII + классический malloc()/free()). Т.е. просто деаллоцируются куски дерева объектов/ресурсов рекурсией. Ну или какой вариант key-value in-memory базы данных со своим отдельным менеджером памяти (LMDB/MDBX). Java offheap примерно для этого и делается обычно, чтоб сотни гигабайт не ворочать этим garbage collector каждый раз.

Подобный подход (разделение объектов по времени жизни) решает почти все проблемы С++, от висячих указателей до memory reuse и утечек памяти. В случае краткосрочных регионов память управляемо (не разработчиком) освобождается целиком в конце внешнего вызова, и ошибочно переиспользоваться ну никак не может. А долгосрочная управляется своим отдельным менеджером памяти, там и вовсе можно защит от души понаставить (вплоть до mprotect() каких).

Создатели Rust-а, кстати, в эти моменты с временем жизни объектов не врубились на старте, вместо этого нагородили свою систему "верификации", а она напрягает и разработчика и код в итоге проигрывает аналогичному на C/С++ (как минимум уж точно не выигрывает).

Такое себе, и на поле Java не смогли толком забраться, и С++ потеснить не получилось, вот и мечутся, пытаясь понять, кто они теперь - более быстрые или более умные/правильные.

решает почти все проблемы С++, от висячих указателей до memory reuse и утечек памяти

Висячие указатели с помощь raii (и его грамотного применения) уже решены. Если ресурс жив, значит объект-владелец не висячий, если мёртв, то и объекта в текущей области видимости нет.

Висячие указатели с помощь raii (и его грамотного применения) уже решены.

Правильно сказать - декларируется, что решены. С оговорками на правильность применения и дополнительные расходы по памяти и CPU.

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

Приведите пример, где raii вносит неустранимые расходы по памяти и CPU, пожалуйста.

Ну это ж классический tradeoff между скоростью и потреблением памяти. Если памяти много - то реально можно смириться с тем что какие-то куски потом когда-нибудь освободятся. Зато не нужно подсчитывать ссылки, да. С другой стороны, в своей работе я редко вижу когда софт упирается в скорость процессора. Чаще - как раз в объем свободной памяти.

 С другой стороны, в своей работе я редко вижу когда софт упирается в скорость процессора. Чаще - как раз в объем свободной памяти.

Замечание справедливое. Но нужно правильно понимать, что в современном мире есть условно 64 бита, где про ограничения памяти можно уже вообще не думать.

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

Потому утверждение, что obstack не применимы ибо "а вдруг память" закончится - оно сугубо академическое. В конце концов это все можно довольно просто замерить (перекрыв/протрасировав new / malloc() вызовы). Обычно в 99% случаев там даже до пару мегабайт краткосрочных объектов на внешний вызов не доходит, а 95% случаев - даже до 640кбайт, если говорить про какой веб сервис :)

Все, что вы написали безусловно абсолютно правильные и логичные рассуждения. Только вопрос в том, как разделение на краткосрочные и долгосрочные объекты удобно и красиво выразить в коде? Ведь тот же service call это не просто один вызов, где можно все объекты аллоцировать на стеке и они потом умрут сами собой, это может быть множество асинхронных операций, которые шарят какие-то данные между собой. Как аллоцирование всех этих объектов объединить одной нитью в коде, чтобы это не выглядело "ugly" и при этом минимизировать риск ошибок? Использование того же obstacks в лоб выглядит достаточно сложным.

это может быть множество асинхронных операций, которые шарят какие-то данные между собой.

Если это два внешних асинхронных вызова одного сервиса (chat-service?) - то там все просто, in-/cross- process message passing, shared in-memory database и т.п. И никаких direct memory references. Между двумя внешними вызовами разница в бесконечность (0.1..5 секунд), а obstack может жить лишь миллисекунды by contract.

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

В общем случае (надеюсь я тут не сильно много крамолы пишу) обработчик внешнего события должен инициировать все ему нужные асинхронные вызовы внутренних микросервисов, затем сохранить куда-то в in-memory database свой контекст (session id, cookies, URI, variables, socket fd, etc) и ... завершить выполнение/освободить obstack. Передав менеджеру запросов свой подсохраненный локально контекст с напутствием - "ну там когда вот все те микросервисы свой payload отдадут, через сколько там миллисекунд, ты вызови меня снова с вот этим session_id, я и займусь (до)обработкой того, что они мне там понаприсылали".

Ну а менеджер запросов (который epool based) должен уже обеспечить все эти внутренние вызовы, HTTP(s) request/response и прочее, у него внутри логик особо и нет, кроме вот этих кому куда чего слать и кого потом заново вызывать для продолжения обработки, ломаться на dangling pointer там нечему.

Кстати да, вот тут на внутренних микросервисах появляется уже третий класс памяти - session based obstacks (в дополнение к service method call obstacks и к long-living in-memory database). Которыми рулит Session manager: если очень грубо - под каждый socket fd заводится свой session level obstack region, куда сначала из сокета пишется request, потом response, ну и после отправки response можно снова session level obstack memory free/reuse делать/следующий keepalive request обрабатывать. Вот тут извиняюсь, не хотелось сразу все секреты выдавать :)

Ну а если внутренние вызовы не асинхронные, но долгоиграющие (классика - вызов SQL базы данных)... что мешает их сделать асихронными через внешний или внутренний брокер сообщений с пуллингом, ну или просто сразу синхронно подождать, пока там SQL база не вернет свое, беря в расчет то, что ограничения ее внутреннего throughput никакой асинхронностью не преодолеть (ну не может никакая SQL база в одновременные 10к SELECT, даже в 100 уже не может).

А вот для долгосрочных объектов работает банальная parent-child древовидная модель (ну... ок, можно сказать RAII + классический malloc()/free()). Т.е. просто деаллоцируются куски дерева объектов/ресурсов рекурсией. Ну или какой вариант key-value in-memory базы данных со своим отдельным менеджером памяти (LMDB/MDBX).

В эту схему не укладывается случай, когда долгоживущие объекты 1) используются и даже создаются на fast path — in-memory database слишком медленные; 2) должны освобождать свои ресурсы, как только стали не нужны — они дефицитные; 3) могут уничтожаться вместе с родителем в иерархии, но держит их живыми не родитель, а использующие. Ядро не буду приводить в пример, так как там всё особенное. Но вот userspace драйверы в DPDK именно такие, и там масса багов от того, что написаны они на C с самопальными счетчиками ссылок. Высокопроизводительный и экономный системный софт, в общем.

В эту схему не укладывается случай, когда долгоживущие объекты 1) используются и даже создаются на fast path — in-memory database слишком медленные; 

Для долгоживущих объектов можно использовать все что угодно из того великолепия, что было сочинено в индустрии. Не устраивают миллионы рандомных доступов в секунду на каких b-tree in-memory keyvalue - никто не запрещает какую lock-free hashmap с хитрым btrie взгромоздить. Или по классике shm с семафорами, и т.д. и т.п.

Я больше акцентировал том, что посмотрите/отделите короткоживущие объекты в пределах контекста внешнего вызова, им обычно не нужен классический new/delete, malloc()/free() memory management, да еще и в конкурентом виде, с подсчетами ссылок. Если их на уровне дизайна отделить от долгоживущих, то можно сразу многое упросить. В том числе получить бенефиты в NUMA средах, когда вместо хаотичного доступа к разделяемой куче основная работа будет идти в worker's binded obstacks/memory regions, которые часто даже за пределы локального кеша L3 не убегут, не успеют, там и будут в итоге "обнулены/очищены".

Даже вот эта самая задача парсинга, что в топике озвучивалась. Имеем на входе некий короткоживущий request payload с каким JSON или XML. Прогоняем его stringcopy-free, lock-free парсером, получаем некий массив из структур (или просто вызовов) вида int type, int offset, int size, никакие строки из этого JSON или XML пока отдельно копировать и аллоцировать деревьями не надо, боже упаси.

И уже затем отправляем куда-то в разделяемое хранилище вызовы со string_view параметрами (pointer+size), ссылающимися на исходный request payload blob, и пусть вот это хранилище само куда там ему надо себе потом накопирует нужных строк-данных из исходного JSON blob-а в свои какие конкурентно-доступные структуры.

А в конце вызова этот короткоживущий request payload вместе с массивом от парсера (которые оба изначально сидят в obstack) берем и удаляем переводом HWM в ноль. Никакие счетчики ссылок при этом не нужны, как и примитивы многопоточной синхронизации, зачем там что-то усложнять?

то особого смысла в obstacks я не вижу.

Большая ошибка в том, что ты сейчас пытаешься memory regions/obstacks воспринимать как нечто, что требует ручного управления в стиле new/delete, malloc()/free().

А это не так.

Через obstack можно на С++ программировать в стиле Java, вообще не задумываясь об освобождении памяти. Если сегрегацию классов памяти (долгосрочный или краткосрочный) сделать просто на уровне перекрытия метода new - чтоб определиться, какой это будет класс объектов - краткосрочно контекстно зависимый (автоматическая деаллокация региона в конце внешнего вызова) или долгосрочный, то встроенный менеджер памяти можно сразу выкинуть на помойку (правда вместе со всем STL, но это не такая и большая потеря, что нам строит свой правильный STL запилить :). Впрочем, не будем так сильно пугать, для долгосрочного контекста STL вполне пригоден в текущем виде.

Да, а если про прям классический локальный GUI (Qt, GTK, VCL, etc) говорить, там аналогично веб-сервисам на obstack все можно строить. К примеру какое дерево DOM целиком живет долгосрочной памяти, или и вовсе в в key-value inmemory database (а почему и нет?) А все вызовы обработчиков событий - работают аналогично вызовам внешних веб сервисов. Кликнул пользователь на кнопку, краткосрочный контекст открылся, что-то там обработалось/аллоцировалось, в какой свой локальный DOM изменения понаделали через специальный API (который не приемлет всякие эти ваши страшные reference pointers, только строки на стеке и прочие числа), а в конце этого мышеклик обработчика его локальный obstack раз и освободили (автоматически, на уровне библиотек runtime).

Впрочем, индустрия пока туда еще не доросла, видно сильно страшно представить себе "а вот теперь мы выкидываем STL и весь к нему приложенный ISO комитет на свалку".

И дело там не в перфомансе аллокаций-деаллокаций. Тут больше дело в самом принципе разработки ПО, в защитных механизмах, обеспечении корректности by design. Только за счет исключения возможности ручных деаллокаций/ошибочного переиспользования памяти (в т.ч. возможности прямого запрета ссылаться из долгосрочных объектов на краткосрочные) можно сразу решить целый пласт проблем безопасности ПО (то, что условно успешно пытаются решить Rust, Java и прочие С++ заменители).

Sign up to leave a comment.

Articles