Всем привет, я Ваня Ходор, и сегодня я вам расскажу про НРВО.
Почему я хочу про него рассказать?
У меня в рабочей практике примерно раз в месяц случается такая ситуация, когда в какой-нибудь чате или в обсуждении в плуреквестия начинается жаркая дискуссия про то, правильно ли из функции или метода возвращается переменная.
И мы тратим какое-то не нулевое время на то, чтобы разобраться в очередном случае.
Вот я хочу рассказать сегодня некоторые вещи, чтобы вы больше на это время не тратили.
Пишу на c++-прод уже примерно четыре года, до этого чуть больше.
Сейчас руковожу бы к эндам каталога и эндекс-славки, люблю активные активности, веду блог в телеграмме, про плюсы, программирование и все связанное.
Ну и давайте сразу поедем разговаривать про nrvo.
Мы будем смотреть на какие-то кусочки кода, у нас там будет использоваться такая структурка, у нее все просто понятно, есть несколько конструкторов и член-классы на всякий случай, вдруг пригодится.
Самый простой пример NERVO у нас на слайдике.
Есть функция F. В ней создается экземпляр структуры S. И эта переменная возвращается.
И здесь, возможно, вопреки чем-то ожиданиям у нас происходит только вызов конструктора по умолчанию.
Нет никаких копирований, никаких перемещений.
Собственно, это и есть NERVO.
Такой код мы, конечно, обычно не пишем.
Даже не такое, но похоже на правду.
Создали переменную, как-то ее поменяли, куда-то передали и вернули.
И здесь опять же только конструктор по умолчанию, ну и плюс какие-то измененные вещи, которые вы делали по ходу в своем коде.
Но, не всегда очевидно, происходит ли это оптимизация.
непонятно в случае первого ритурна.
Будет у нас вызов конструктора или еще что-то, или только вызов конструктора, и в случае второго ритурна.
А давайте пробуем разобраться.
Разбираться будем на примере кланга, у него открытый исходный код, можно залезть, покопаться.
И если мы уйдем исследовать код, мы обнаружим такой класс.
Он используется при построении абстрактного синтактического дерева нашего кода и является
что, наверное, очевидно, репрезентация области видимости.
То есть мы парсим области видимости и набираем в этот класс, в экземплярии этого класса какие-то данные.
Здесь есть два важных члены класса, которые нас интересуют.
Первый — это Nrvo, второй — Return Slots.
У обоих у них можно увидеть WarDecl.
Какой-то WarDecl — это по факту репрезентация объявлений или определения переменной.
И вот Nrvo — это с тудовшим над указательным на WarDecl.
В NRW мы храним какого-то валидного кандидата на NRW в рамках нашего скопа, и у него три состояния, ну, лопт, это значит, что мы еще не нашли никаких кандидатов, какой-то валидный указатель на WarDecal, не ну, PTR, это значит, что вот этот кандидат у нас уже есть.
И ну, PTR, это значит, мы выяснили, что NRW в скопе невозможно.
Возможно, здесь название не самое удачное.
Вообще, Return Slot — это место в памяти, где будет эластирован результат выполнения функции или метода.
Но здесь это, скорее, все кандидаты, которых мы еще не просмотрели, мы не выяснили.
Они действительно могут быть как НРВО или...
Или они нам не понадобятся, они понравятся, и мы их удалим.
При этом, в NERVOM, мы храним уже ковытовленного кандидата, о котором мы проверили.
В нашем примере все просто.
У нас есть scope функции, это scope 1, есть scope f, scope 2.
В каждом мы вызываем добавление в Return Slot, добавление переменной.
И когда, собственно, когда парсим код сверху вниз, и в нашем случае получается, что в scope 1 в Return Slot лежит y,
В сколпе втором в ReturnSlot лежит s. Теперь нам нужно каким-то образом понять, какие кандидаты у нас все-таки являются более валидными.
Цепочка здесь примерно упрощенная, но суть передает хорошо.
Мы парсим код, в какой-то момент мы начинаем парсить тела функции, в какой-то момент в функции мы, наверное, будем парсить ритурн, и при парсинге ритурна нам нужно совершить некоторый набор действий.
Они совершаются в функции ActonReturnStatement.
Одно из таких действий — это как раз обрядить нервового кандидата.
Возможно, это будет один из самых сложных слайдов, но если здесь будет непонятно, то дальше будет пример.
Здесь мы хотим выяснить, правда ли мы можем нашу переменную положить в ретурн-слот нашей функции.
Собственно, мы пытаемся собрать такой флажок.
И он будет тру, если хотя бы в одном скопе мы найдем валидного кандидата на NRWO.
То есть мы идем от текущего скопа, в котором мы находимся.
Родительский скоп — это скоп, в который вложен наш скоп.
И для каждого скопа проверяем валидность нашей оптимизации.
У нас есть вот такая лямба, здесь захватывается Vardecal.
Это Vardecal переменный, который возвращается в нашем ритурне.
То есть она где-то была объявлена, и сейчас мы в ритурне ее снова встретили.
И по факту, что мы хотим понять?
Мы хотим понять, в Return Slots нашего сколпа есть ли вообще такой кандидат, который является объявлением этой переменной?
То есть по факту мы проверяем, была ли объявлена эта переменная в рамках нашего текущего сколпа?
Удаляем все после того, как это выяснили.
И если объявление этой переменной встречалось, то возвращаем ее обратно.
То есть по факту у нас два варианта.
Либо мы удалим все и ничего не вернем, либо мы удалим все и у нас останется только один подходящий кандидат.
И, соответственно, все остальные кандидаты у нас не валидны.
То есть нам нужен только один НРВО кандидат.
В нашем примере мы уже распарслили первый несколько строк и пришли к ритурному.
Здесь у нас возвращается переменная s. Это значит, что мы вызываем updateNrVolCandidate для этой переменной и смотрим в returnSlotScope.efam.
Здесь мы находим этот foredeckle и запоминаем флажок true, удалили, вернули обратно, по факту ничего не изменилось.
Здесь мы видим, что Vardecal S у нас отсутствует в наших Return Slots.
Соответственно, мы получаем flag false.
И мы должны очистить этот самый Return Slots нашего первого Scope.
Получается, у нас теперь не будет кандидатам на NRWO.
Окей, когда мы насобирали флажок, мы, собственно, выясняем, можем ли мы взять этого кандидата к себе в NRW или нет.
Если да, если flag true, то мы просто запоминаем WarDecl в NRW член-класса нашего скопа.
Ну или noPtr, это значит, что мы не нашли никаких кандидатов, оптимизация невозможна.
Получается примерно такая ситуация.
Для Scope2 мы запомнили, что nrwo — это переменная s. И это значит, что нам нужно теперь как-то применить эту оптимизацию.
Сейчас мы подходим к концу парсинга Scope.if, и будет вызываться функция ActonPopeScope.
Она вызывается в разных случаях, в том числе, когда мы парсим Scope.if, заканчиваем, заканчиваем парси тела функции.
И в ActonPopeScope вызывается ApplyNrwo.
Это тоже функция в Scope, метод.
Мы проверяем, есть ли у нас какой-то валидный кандидат на nrvo, если нет, то делать нам нечего.
Сначала мы разаменываем наш std optional и смотрим, какой указатель там лежит.
Если это на uptr, соответственно, оптимизация у нас невозможно.
В рамках сколпа мы это выяснили.
Уйдем дальше, скипнем часть логики.
Если там есть какой-то валидный указатель, то мы передадим его в метод издекал-скоп, а издекал-скоп проверяет, правда ли, это переменная, объявлена в рамках скопов, которого мы сейчас находимся.
Потому что если это неправда, то нам не зачем разбираться в скопе с, можно сказать, чужой переменной.
Это разборка произойдет в скопе, в котором она объявлена.
И если все круто, если все сошлось, мы просто в ардеклу, которая лежит в nrvo, устанавливаем флаг, что это nrvo переменная.
Ну и опуская некоторые технические моменты, у нас в конце есть еще пропагация кандидата nrvo в родительский сколп.
GetParent возвращается как раз в родительский сколп, и мы тогда отдаем нашего кандидата на nrvo.
Получается, мы переменно s установили flag true, и это наше nrvo переменное.
В рамках hf у нас происходит nrvo.
Давайте добьем наш пример и закончим парси эти функцию.
Здесь у нас есть еще return y. Мы вызываем update nrvo кандидата для y.
и проверяем наличие кандидата вообще в нашем первом сколпе.
Но так как мы его почистили, когда искали кандидатов для переменной s, здесь у нас не находится никакого вардекла.
Соответственно, у нас nrvo-кандидат для этого сколпа будет равен uptr.
И это значит, что nrvo в рамках функции у нас невозможно.
А что дальше вообще происходит с этим флагом?
Как же заканчивается реализация этой оптимизации?
Когда мы приходим к генерации Intermediate Presentation нашего кода, у нас находится класс, который называется Corgan Function, и он отвечает за генерацию IR для функции.
И у него есть такая функция, метод, который решает, где нужно лоцировать.
Какую-то конкретную переменную.
Вот в данном случае эта переменная лежит в Вардекле под названием D. И есть очень важный кусок кода.
Здесь мы садуем переменную NRWO.
Она стоит из двух частей.
Во-первых, включена ли вообще эта оптимизация в компиляторе на данный момент.
И второе является ли переменная, которую мы рассматриваем NRWO переменной.
Нам нужно заполнить два технических поля.
Первое – это адрес и второе – аллог-адр.
Аллог-адр – это прикола компилятора.
На него осмотреть не будем.
В смысле тут интересного никого нету.
Вот поле адрес означает место в памяти, где будет лоцирована переменная, которая лежит под вардеклом D. То есть по факту мы сюда должны написать какой-то адрес.
И мы сюда записываем значение из переменной Return Value.
А Return Value как раз это
адреса ретрунслата функций, то есть по факту места в памяти, где ожидается результат функций.
То есть здесь мы явно говорим, я хочу лоцировать переменную ПОВРД КЛАМД в ретрунслате нашей функции.
Примерно на этом заканчивается оптимизация, реализация этой оптимизации в кланге.
Давайте пробуем сделать некоторые выводы.
Но, во-первых, NRW — это оптимизация этапа симметического анализа.
Это довольно ранний этап.
Мы еще парсим код и пытаемся на ходу проверять, возможно ли это оптимизация.
И поэтому иногда она работает довольно плохо.
Например, здесь у нас есть шаблон функции.
Мы видим, что здесь может передаться в него bull.
В зависимости от була скомпилируется код или может не скомпилируется код.
Даже если он скомпилируется, это все равно и false.
И по факту наша вся функция — это создание переменной s и возврат этой переменной s.
И нам понятно, что это должно быть на рву.
Но из-за того, что у нас здесь есть ретурн, который никогда не выполнится, просто по структуре кода компилятор не понимает, что эта вещь на самом деле нам не нужна.
Он думает, что условия не выполняются, и у нас оптимизация ломается.
Правда ли, что все ретурнскопы возвращают одну и ту же переменную?
Просто потому что если это не так, то когда вы пытаетесь обновить кандидатов в ритуру слоте для какой-то переменной в одном из ритурн, вы затрёте все остальные.
Соответственно, у вас стоит только один кандидат, и если в другом ритуру встречается уже не эта переменная, то он тоже затрётся, и в итоговом сколпе у вас будет ноль кандидатов на NRW.
Ещё один факт – nrvo может пропагироваться.
Оно может пропагироваться в двух смыслах.
Здесь у нас вызовы нескольких функций по цепочкам.
И переменная s из функции f1, она создаётся по факту сразу в ретурном слоте переменной s2.
Здесь у нас родительский сколп функции не имеет ритурна.
Здесь у нас есть ритурна только в сколпе и фа, а в родительском сколпе все функции есть exit 0.
Ну и при этом понятно, что если у нас нет других вариантов, кроме как вернуть s, то мы можем создать ее в ритурном слоте.
То есть здесь происходит нерво.
Еще один факт очень важный.
Возвращаемый тип функции или метода должен совпадать с типом возвращаемый переменный.
Потому что если это не так, то получается, что у вас есть ретурн слот для переменной одного типа, а вы туда пытаетесь положить переменную другого типа.
И в среднем все плюс-плюс такое не любят.
Давайте посмотрим на еще несколько примеров.
Попробуем понять чуть глубже.
Например, слева, который мы уже разобрали.
И давайте попробовать это объявление переменной Y, перенести после FAM.
и прогоним примерно тоже загореть, но чуть быстрее.
Сыфом тут все понятно, у нас есть ритурный слот с пустой для сколпа 1, ритурный слот с не пустой для сколпа 2, мы найдем объявление fardecal, переменной s, когда будем парить ритурн, здесь случается нерво.
Ну, конечно, когда мы идем сверху вниз и парьсям на шкод и встречаем объявление переменной игр, мы его добавим в ритурный слот с первого сколпа, а это значит, что мы сможем его найти при парсинге ритурно.
Это означает, что теперь у нас оптимизация случилась.
Получается, что при переносе переменной на несколько строк ниже иногда оптимизация у нас может магическим образом заработать.
Немножко отойдем в сторону ритурной StdMove.
Здесь довольно понятный код, я думаю, популярный.
У нас есть два объекта, мы хотим их вернуть в паре и мы заворачиваем, создаем эту пару прямо в ритурне и получаем две копии.
Копии мы не сильно любим.
Но иногда есть по аналогии такой соблазн написать аналогично и здесь.
Мы создаем переменную и муваем ее.
Теперь компилятор обязан создать эту переменную в рамках функции, то есть как локальную переменную, и вы просто себе таким образом ломаете нерву.
При этом, даже если бы оптимизация не случилась, он все равно интерпретировал бы эту переменную, этот объект как хэрвелью и все равно был бы мув.
То есть таким образом, мы сделали себя только хуже.
Когда еще нервон не работает?
Например, когда у нас возвращается стазическая переменная.
Просто потому, что мы не можем говорить, где ее создать.
Она создается в другой области памяти.
Там в Data Section или в BSS они на стеке.
И соответственно, оптимизацию здесь мы осуществить не можем.
или два других примера, когда у нас типы не совпадают.
Почему это может быть еще проблема в подтверждение?
Например, первые две строки — это про то, что мы пытаемся из функции получить родительский тип, но возвращаем дочерний, и в данном случае у нас дочерний тип имеет еще дополнительное поле.
Это значит, что если у родительского типа сайзов, например, 8 байт, то у дочернего он будет здесь 16, и мы не можем чисто физически корректно запихнуть 16 байт в ретрун слот размером 8.
Ну или второй пример, функция F4.
У нас здесь есть std-optional над std-stringом, и мы возвращаем просто std-string.
Ну понятно, что это разные типы, у них разные layout в памяти, и тем не менее могут даже различаться соизофы и положить просто байты одного типа, одного объекта в место для другого тоже неволидно.
ну или не работает сострача bindings ну а почему вот вроде у меня есть здесь определение создания двух переменных с ай одну я возьму доверну и это вроде как выглядит как и нерву но будет копия
Потому что в принципе бизнес разворачивается примерно в такую штуку.
SI — это ссылки на члены нашей структуры.
И если мы хотим, чтобы нервово здесь работало, нам нужно один член класса положить в ритурный слот, а второй должен остаться где-нибудь в другом месте.
Но насколько я знаю C++, они должны находиться рядышком, если это не указатели на кучу.
А как понять вообще, что я написал код круто, что у меня все работает, что ничего не сломалось, чтобы пацанам во дворе показывать, смотрите, я пишу эффективно.
Ну, можно посмотреть на ST кланго.
Он поможет выяснить все ли корректно с точки зрения этой оптимизации.
Вот, посмотрим такую функцию.
Здесь мы сразу видим, что нервой не происходит.
У нас есть ритурн в уфе, он возвращает переменную ну, и ритурн в конце функции он возвращает эс.
Ну, эс, понятно, она нам, во-первых, порстит малину с нулом, а в первом ритурне во-в-трых у нее еще и тип не совпадает.
Ну, давайте посмотрим на IST.
Заходим на GoodBolt, открываем дню, тыкаем IST и видим вот такое полотно.
Ну, нам такое полотно изучать не нужно, нам нужно чуть меньше.
Нам нужно посмотреть на WarDecal переменной NULL.
Это, собственно, тот же WarDecal, который мы видели в Scope, в членах класса Nervo и RetroSlot.
И здесь мы видим, что это WarDecal, где эта переменная объявлена.
Какие-то служебные пометки, кого-нибудь,
Сейчас пока ничего интересного тут нет, давайте код немножко изменим, чтобы нерву у нас точно было.
Мы поменяли последний ретурн, теперь у нас возвращается не санул.
Логика изменилась, но научный интерес, ничего страшного.
Смотрим на WarDecal и видим пометку, что здесь у нас переменная помеченная как нерву.
Эта пометка здесь выводится ровно по тем же причинам, по которым у нас генерируется корректный AR.
То есть WarDecal после того, как мы выходим из Scope,
получает флаг, что это NRW переменная, и когда мы пытаемся вывести IST, этот флаг проверяется и пометка вводится, и из того же флага генерится корректной логиком локации переменной.
и немножко от этим другим в сторону, что у нас с другими компиляторами.
Clang в целом, как GCC, они вместе включают нерву по умолчанию, MSWC включают только с оптимизациями или начинаются плюс-двадцать.
В целом, примеры, на которых у них оптимизация работает или не работает, плюс-минус одинаковая.
И здесь можем немножко идти в сторону про то, что еще может быть полезного такого в рамках этой оптимизации.
В небезвестном языке Carbon есть такая штука, как ключевое слово returned.
Когда вы помечаете примерно Returned, вы по факту делаете ассерт на NRWO.
То есть если NRWO в рамках вашего кода не происходит по каким-то причинам, вы можете сказать, точнее компилятор вам скажет, что у вас тут какая-то проблема не сошлось.
Вklonk в целом были предложения завести какие-то подобные атрибуты, но за потенциальной невосребностью у разработчиков их решили не реализовывать.
Но это довольно сложное и поэтому на данный момент необязательная оптимизация.
И здесь каждый компилятор делает, что может.
По-хорошему реализовывать нерву они на том же этапе, на котором у нас сейчас.
Унести куда-нибудь попозже, когда мы уже знаем, какие у нас проблемы.
какой кот у нас исполняется, какой нет.
И в целом есть даже проползал по номерам P225, завтра с вам одного из разработчиков и сервер, Антона Жилина, где он разбирает различные случаи, где нарвоз сейчас не работает, хотя вообще-то могло бы, и предлагает сделать эту оптимизацию гарантированный.
Но, судя по всему, в ближайшие пару стандартов он не доедет.
И буквально 15 июля появился другой, более простой в реализации проползал.
Автор считает, что, так как прошлый попозл P2025 подзавис, то, наверное, можно сделать что-то более простое, чем изменять поведение в ядре языка и просто подвести несколько библиотечных функций.
Но, кажется, на него не сильно будут засматриваться, потому что зачем нужно какой-то
Отдельное решение, которое решает частный случай и мешает писать код в обычном флоу, если есть вот замечательный, полностью покрывающий все вопросы и пропозл.
Ну и какие-то рекомендации.
Если мы хотим писать код круто, эффективно с точки зрения нервов,
Первое, тип возвращаемого значения должен совпадать с типом в фигментуре вашей функции или вашего метода.
Второе, лучше всего, если вы будете пытаться возвращать одну локальную переменную в рамках области видимости.
Конечно, вы можете этого не делать, вы можете возвращать разное, но тогда, если вы хотите быть уверенными, что с кодом все круто, вам нужно идти разбирать случаи, смотреть, как это оптимизация работать,
проводить вот эти все вычисления на бумажке, проще всего, наверное, на это все забить и возвращать одно и то же.
Ну, конечно, если не страдает читаемость вашего кода.
У меня все, жду ваши вопросы.
Поднимается руки, задавайте вопросы.
Кто-то еще с левой части зала поднимал.
Дайте, пожалуйста, тоже микрофон, чтобы вы в следующем задали.
Так, раз-раз, спасибо за доклад.
Вот такой вопрос касательно влияния соглашения о вызовов на реализацию данной оптимизации.
Допустим, там эстодекол и седекол.
Особенно помню, это влияет на стек очистки вызова функции, то очистит ее и вот такой вопрос, как это влияет, если влияет вообще на данную оптимизацию.
Если вы создаете локальную переменную сразу в ретрум слоте, то у вас в области памяти в стейке, которую занимают локальную переменную вашей функции, становится по факту меньше занятого места.
Соглашение о вызове функции не учитывает такие возможные приколы.
То оптимизация может быть неволидна или что-нибудь сломать.
Но, наверное, просто общий ответ.
Никаких-то конкретных вещей я-то не знаю.
Так, мне тут дали микрофон.
Один вопрос, в первой точке, не даже.
Работает ли НРВО с юнионами?
Ну, то есть типами перечисления, в принципе.
Если возвращать именно тип, ну, не тип юниона, а вот член юниона.
Ну, сейчас, я так понимаю, что
Если у вас ваша переменная, это надо наверно уточнить.
Мы говорим про возврат целого Юниона, в котором какой-то член валидный есть.
Или мы говорим про возврат отдельного члена?
Про возврат отдельного члена.
Если возвращать Юниона, а если вот типа не совпадают, но Юнион вроде layout, все там должно быть корректно, компилятор должен бы понять.
Но я не уверен, на самом деле, но, может быть, можно протестить после вкулорах, можно посмотреть на год болт, что получится.
И такой еще небольшой вопрос.
Работает ли НРВО с дизигнетит инициал-дилизаторами?
Ну, когда мы структуру через точку и члены инициализируем, вот там оно будет отрабатывать или нет?
А мы заполняем член, собственно, нашего класса.
Мы заполняем член на структуру вызовом функции каких-то.
В целом, не вижу причин, чтобы это не работало, а, кроме того, чтобы это не поддержали, тоже надо посмотреть.
Видела понятую руку вот здесь.
Дайте, пожалуйста, микрофон.
Вот я услышал про ключевое слово «ретёрн» в карбоне, а нюж-то нету какого-нибудь там атрибута компиляторно зависимого в кланге или в ГЦС, чтобы, ну, проверить и как засёртеть на то, чтобы Рнорова произошёл.
Ну вот про атрибут я говорил.
Мы знакомы и предлагали, собственно, в кванг такой затащить, но в обсуждениях сказали, что в целом это можно сделать, но как будто нет смысла, потому что вряд ли очень много разработчиков на C++ каждый раз вообще беспокоятся об этой оптимизации и будут в каждом нужном месте его пихать.
Вот, то есть это довольно специфичная узкая штука.
А вообще, насколько я знаю, один из
Популярных спикеров на Сиппиконе Артур Одвайера, он когда-то пилил в клонктайде проверку на то, вообще, работает у вас штука, это оптимизация или нет, вот можно пользоваться там.
Давайте последний вопрос, вижу поднятую руку, третий ряд, левая, правая секция.
Вот в кейсе, когда возвращаем стэдопшину, а от стринга там муф, у чего будет вызываться у стринга или у стэдопшину?
Просто, допустим, если у стринга, то это понятно, это невозможно переписать даже на выручную, а или у стэдопшину.
Ну вообще, это, короче, можно какую-то миндальную модель себе сделать.
По факту, когда вы возвращаете, если объект рассматривается как арвелию, то по факту вы конструируете новый
в которой у вас передаётся RTL.
Поэтому мувается STD String в новой создаваемой Optional.
Но это, по сути дела, оптимизация всё равно есть.
Мы передаем функцию, указатель на Return value на STD Optional и просто его и целителем Stringом.
Это даже если мы просто пишем такой код как STD Optional равно String.
В любом случае мы мувим String или нет.
Ну, это, наверное, сейчас.
Если мы просто с ДМ объект... Интуитивно кажется, что оптимизация по-прежнему работает, потому что мы не мувим стадопшенол.
Ну, если мы просто пишем стадопшенол оптором, как какая-то строка С, то это не мув, потому что здесь копирование происходит.
Но при этом, когда вы возвращаете строку из функции, у вас будет мув.
Да, потому что строка... Ну, копирование это еще хуже, чем мув.
Но копирование хуже, чем у да, но у нас как бы не Move, не Move Only Design, у нас по-дефолту происходит копирование, для Move надо что-то делать, поэтому если вы пишете просто как одну перемену и другой инцелидируйте в сколпе, то да, будет копирование.
Но все-таки то есть какого-то копирования стыдопшинал не происходит, то есть это уже хорошо на мой взгляд.
Но это лучше, чем ничего, но можно лучше.
Сразу залоцируется тедопшин или потом этот указатель, как бы не олацировать стринг на стеки?
Нет, если мы говорим про то, что у нас типы совпадают, мы в целом не можем говорить про оптимизацию в этом контексте.
Давайте предложу продолжить разговор в куларах, потому что благодарим спикера.
Проводим его аплодисментами.