C++ lectures at MIPT (in Russian). Lecture 9. Smart pointers, part 2

Поделиться
HTML-код
  • Опубликовано: 3 окт 2024
  • Лекции в магистратуре МФТИ по C++ на русском языке.
    Эта лекция посвящена несколько более глубоким вопросам умных указателей: разделяемым указателям, проблеме закольцовки и слабым указателям
    Рассматриваются также интрузивные указатели и почему им не место в C++
    Лектор: Константин Владимиров
    Дата лекции: 3 декабря 2019 года
    Съёмка и звук: Дмитрий Рябцев
    Предыдущая лекция: • C++ lectures at MIPT (...
    Следующая лекция: • C++ lectures at MIPT (...
    Слайды ко всем лекциям: sourceforge.ne...
    Errata:
    пока здесь пусто

Комментарии • 31

  • @elkadaf
    @elkadaf 3 года назад +3

    28:45
    Смотрел я ваши лекции когда-то и после этого долго думал, что shared pointer'ы нельзя ковариантно преобразовывать без std::static_pointer_cast. Но ещё с 2011-го года у них есть конструктор от другого shared_ptr с convertible(11)/compatible типом. И сделать обычный static_cast тоже можно. И ошибок компиляции не будет ;)

    • @tilir
      @tilir  3 года назад +2

      static_cast(derivedPtr) действительно внезапно работает (и это довольно интересно, спасибо за наблюдение)
      Но мне всё-таки кажется, что static_pointer_cast(derivedPtr) несколько проще.
      Ну и кроме того, альтернативы dynamic_pointer_cast нет, так как dynamic_cast требует настоящего указателя (ссылки)

    • @elkadaf
      @elkadaf 3 года назад

      @@tilir насчёт обычного static_cast, это работает и простым присвоением std::shared_ptr ptr = derived_sp;
      Возможно, static_cast несколько более ясно выражает намерение, но в целом, это явно проще громоздкого std::static_pointer_cast. Касательно dynamic_pointer_cast да, но это уже и немного другой случай

  • @РайанКупер-э4о
    @РайанКупер-э4о 2 года назад +1

    1:04:30 А почему нельзя хранить в памяти сначала ref counter и weak ref counter, и только потом данные, чтобы указатель указывал на ячейку с счётчиком, и чтобы можно было по удалению данных сказать компу "мне память после счётчиков больше не нужна, можешь её забрать"?

    • @tilir
      @tilir  2 года назад

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

  • @ivankorotkov2563
    @ivankorotkov2563 3 года назад +1

    1:06:17, слайд 75. Кажется ситуация с shared_ptr по безопасности относительно исключений не отличается от ситуации с unique_ptr с предыдущего слайда.
    Потому что, хотя shared_ptr и аллоцирует память для контрольного блока, что может выбросить исключение, но shared_ptr дает гарантию, что если в конструкторе shared_ptr(Y*) будет выброшено исключение, то переданный указатель все-равно будет удален.
    [util.smartptr.shared.const] 6: If an exception is thrown, delete p is called when T is not an array type, delete[] p otherwise.
    И пункт 11 про то же самое, но для конструкторов shared_ptr с кастомным deleter'ом.

    • @goczt
      @goczt Год назад +1

      Сегодня тоже озадачился этим вопросом, так как прочитал статью, в которой предлагается использовать именно конструктор shared_ptr'a с явным вызовом new. Хорошо запомнилось из материала лекции, что make_shared должен быть безопаснее относительно исключений, поэтому хотел повыделываться в комментах, но чёрт дёрнул перепроверить информацию хотя бы на cppreference. В итоге наткнулся указанную вами строчку и пошёл обсуждать с товарищами инфу. Нашли статью на хабре, ссылки вроде нельзя, поэтому своими словами насколько понял:
      В С++17 было введено правило, исключающее утечку памяти на слайде 75. [expr.call] абзац 8:
      ...The initialization of a parameter, including every associated value computation and side effect, is indeterminately sequenced with respect to that of any other parameter.
      indeterminately sequenced = порядок вычисления неизвестен, но не может быть перемешан (interleaved) компилятором.
      Соответственно до введения этого правила порядок был unsequenced и мог быть перемешан неблагоприятным образом.
      Вопрос тут в том, насколько вероятно, что компилятор поставит вызов throwing функции(в т.ч. другого new) между вызовом new и вызовом конструктора shared_ptr, а потом в рантайме именно в этом месте вылетит исключение, но строго говоря она не 0.
      На слайде 76 же, если я правильно понял, безопасность исключений всегда была строгой и гарантирована стандартом. Кажется именно тут нам немного наврали.

  • @ivankorotkov2563
    @ivankorotkov2563 3 года назад +2

    1:05:19, слайд 74. Прошу поправить если не прав. Правильно ли понимаю, что в C++14 возможна ситуация, когда код
    foo(unique_ptr(new T(1)), unique_ptr(new T(2)));
    вызовет проблемы если компилятор выберет порядок выполнения, при котором сначала выполняются оба new и только затем они передаются в unique_ptr. Тогда если второй конструктор или оператор new выкинет исключение, то первый объект освобожден не будет, ведь его unique_ptr еще не успел сконструироваться?
    И верно ли что в C++17 эта ситуация поправлена и теперь в данном сценарии разница между make_unique() и std::unique_ptr(new T()) сугубо стилистическая.

    • @tilir
      @tilir  3 года назад

      Про "в C++17 поправили" интересно подробней. Что там поправили?

    • @ivankorotkov2563
      @ivankorotkov2563 3 года назад

      @@tilir Я не искушен в чтении стандарта, поэтому спрашиваю, а не утверждаю.
      На cppreference в статье Order of evaluation нашел такое утверждение:
      15) In a function call, value computations and side effects of the initialization of every parameter are indeterminately sequenced with respect to value computations and side effects of any other parameter.
      (since C++17)
      В стандарте самое близкое что нашел, это [expr.call] 5 (в драфте C++17) или пункт 8 (С++20).
      The initialization of a parameter, including every associated value computation and side effect, is indeterminately sequenced with respect to that of any other parameter.
      Соответсвенно у меня вопросы: первый - правильно ли я понимаю, что эти слова применимы к любому вызову функции, или я пытаюсь натянуть их на глобус (в стандарте есть пример, но он совершенно не похож на мой).
      второй - верно ли, что следуя этому правилу компилятор теперь при вызове f(unique_ptr(new T(1)), unique_ptr(new T(2))); обязан после вызова new T сразу создать unique_ptr. Т.е. хотя и не известно в каком порядке объекты будут создаваться, но
      теперь исключен такой порядок исполнения, в котором был риск утечки первого указателя, если в конструкторе второго было брошено исключение, например: new T(1), new T(2), конструкторы unique_ptr).

    • @ivankorotkov2563
      @ivankorotkov2563 3 года назад +1

      Т.е. я эту фразу из стандарта C++17 понимаю так, что порядок вычисления параметров функции неопределен, но если мы начали вычислять значение какого-то параметра, то уже точно доведем его до конца, и не будем переключаться на вычисления других параметров сайд-эффекты которых могут повлиять на вычисления текущего параметра.
      Получается что когда говорим про "неопределенный порядок выполнения" нужно уточнять, имеется ли в виду unsequenced или indeterminately sequenced.
      При этом еще в C++11 было примечание [expr.call] 4: When a function is called, each parameter ([dcl.fct]) shall be initialized ([dcl.init], [class.copy], [class.ctor]) with its corresponding argument. [ Such initializations are indeterminately sequenced with respect to each other ([intro.execution]) ].
      Но видимо это относилось только к непосредственно к вызову конструктора, без учета выражений от которых этот вызов зависит.

    • @tilir
      @tilir  3 года назад +1

      Я только что внимательно прочитал [expr.call] в N4860 и там сказано (clause 8, перевод мой): "... инициализация параметра, включая вычисление каждого необходимого значения и все полагающиеся побочные эффекты, никак не упорядочена с инициализацией остальных параметров ...".
      Что, с моей точки зрения, означает, что всё как было так и осталось. Мало того с точки зрения человека, разрабатывающего компиляторы, мне это кажется вполне естественным: пусть у вас есть вычисления A, B, C, D, E при этом: E зависит от C и D, D зависит от B, C зависит от A. Тогда у нас есть ациклический граф зависимостей и мы его вполне можем линеаризовать например как A, B, D, C, E или B, A, C, D, E или B, D, A, C, E и т.д., то есть в любом порядке топологической сортировки. Мне плохо понятно как (например в терминах LLVM IR) можно добиться такого ограничения, какое вы описали.
      Мне интересно, как вам вообще пришла такая мысль? Просто может быть я не в курсе каких-то последних веяний?

    • @ivankorotkov2563
      @ivankorotkov2563 3 года назад +2

      Опыта в плюсах у меня не слишком много. Навело на такие мысли меня в первую очередь рассматривание примеров с cppreference вроде
      f(++a, ++a); // UB until C++17
      Фразу "indeterminately sequenced" из стандарта я бы перевел не как "никак не упорядочена", а скорее как "упорядочена неопределенным образом".
      Про разницу между unsequenced (неупорядоченные) и indeteminately sequenced (неопределенно упорядоченные) можно прочитать в определении Sequenced before в [intro.execution] 8:
      If A is not sequenced before B and B is not sequenced before A, then A and B are unsequenced. [ Note: The execution of unsequenced evaluations can overlap. - end note ] Evaluations A and B are indeterminately sequenced when either A is sequenced before B or B is sequenced before A, but it is unspecified which. [ Note: Indeterminately sequenced evaluations cannot overlap, but either could be executed first. - end note ]
      Как именно осуществляется обход графа зависимостей при вызове функции во внутрянке компилятора сказать ничего не могу. Мои мысли (вероятно неправильные) по этому поводу такие - изначально неопределенность инициализации аргументов функции возникла из-за того что существуют разные ABI передачи параметров (через стек в любом направлении или через регистры) и если обязать вычислять параметры функции в определенном порядке, это может ударить по производительности в некоторых случаях. Соответсвенно пришли к простому выводу никак не упорядочивать (кажется в терминах sequenced point до C++11 это довольно сложно реализовать). И тот факт что к C++17 смогли доработать эти правила, чтобы с одной стороны внести больше ясности, а с другой не повлиять существенным образом на производительность у меня не вызывает большого удивления.
      Примерно такое у меня направление мыслей.

  • @Николай-ы6к5ь
    @Николай-ы6к5ь 3 года назад

    Умные указателя полезная вещь когда самому лень освобождать память, но только не в библиотеки QT(потомков QObject) когда всё завязано на обычных указателях.

  • @vasilystarostin4697
    @vasilystarostin4697 2 года назад

    Не могу понять, зачем дублируется Data pointer? Если он нужен непосредственно в shared_ptr для быстродействия, тогда зачем его еще и хранить в контрольном блоке?
    А если у нас контрольный блок вместе с данными, то Data pointer по идее вообще не нужен, т.к. совпадает с указателем на контрольный блок (либо имеет смещение, известное на этапе компиляции).

    • @tilir
      @tilir  2 года назад +1

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

  • @samolisov
    @samolisov 2 года назад

    1:13:46 Если будет shared_ptr, то как в эту const string делать write, тот который в COW? Только если ресетить одну из копий shared_ptr на другую строку. А если добавить const к самому shared_ptr, то оба его указателя: на контролблок и на данные станут константными и этот reset мы как раз сломаем, но если захотим поменять строку, то проблем быть вроде бы не должно: сам указатель на строку стал константным, а данные, на которые он указывает - нет. Будет просто write, без copy или я ошибаюсь?

    • @tilir
      @tilir  2 года назад +1

      Так в том и смысл COW что когда вы делаете write вам надо делать новую. Поэтому shared_ptr это практически оно и есть: хотите новое форкаете, а старое не требует реального копирования.

  • @samolisov
    @samolisov 2 года назад

    Ещё хотел спросить про применимость умных указателей вообще. В том же LLVM unique_ptr используется очень часто как тип результата различных порождающих функций. Во времена до C++17, когда copy elision не был обязательным, это было разумно, т.к. избавляло от возможного копирования большого модуля после порождения. LLVM на C++17 ещё не перешёл, но если абстрагироваться от проекта, то во времена C++17 можно возвращать из функций просто значение, главное, чтобы оно помещалось на стек. Получается через умный указатель есть смысл возвращать только какие-то большие объекты, которые на стек не помещаются или когда их размер априори неизвестен на этапе компиляции, как у дерева или связного списка.

    • @tilir
      @tilir  2 года назад

      Я думаю тут дело в том, что возвращать Module можно только если вы уверены что для него move дешевый (я не уверен). А для unique_ptr Благодаря heap indirection он всегда дешевый с гарантией.

    • @samolisov
      @samolisov 2 года назад

      @@tilir а если move недешёвый, но мы и не собирались никуда мувить? auto M = make_super_huge_module(.....); Отработает без копирования благодаря copy elision, а в функции передавать по ссылке, например?

    • @tilir
      @tilir  2 года назад +1

      @@samolisov это мы сегодня не собирались =)
      Ну и потом. Передача по ссылке и передача владения это разная семантика передачи. С точки зрения многопоточного кода для ссылки может быть недостаточно внешней синхронизации (так же как и для указателя). А в случае с unique_ptr если внутренний указатель не утёк и владение действительно уникально, мы можем думать о нём в многопоточной программе как о значении.

  • @timurtsotniashvili2311
    @timurtsotniashvili2311 3 года назад

    А нельзя ли решить проблему слабых интрузивных указателей с помощью shared_ptr таким образом:
    Помимо reference counter мы заводим переменную shared_ptr aliveByIntrusivePtr_. Класс intrusive_weak_ptr хранил бы указатель на объект а также shared_ptr aliveByIntrusivePtr_. Таким образом мы как бы “шеруем” bool между intrusive_ptr и intrusive_weak_ptr. Как толко объект бы уничтожался, *aliveByIntrusivePtr_ получал бы значение false и мы таким образом знаем “живой” ли ещё объект на который указывает intrusive_weak_ptr. Протестировал. Кажется работает все.

    • @tilir
      @tilir  3 года назад +1

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

  • @victormustya1745
    @victormustya1745 4 года назад

    В приведении (слайд 57) не раскрыта тема reintepret_pointer_cast

    • @tilir
      @tilir  4 года назад +3

      Я к сожалению не встречал разумных применений этой фичи так что скромно о ней умолчал ))