Довольно долго я тягался с по-настоящему глупой проблемой на C++: мне не нравятся функции-члены, но я вынужден их писать, чтобы программисту было хоть немного удобнее работать. Функции-члены обеспечивают две вещи: разграничение областей видимости и обнаружимость. Разграничение областей видимости — менее актуальная из этих задач, поскольку в моём коде на C++ я и так не использую модификаторы private/public. Обнаружимость — большая проблема: я могу написать x.F, а IDE предложит x.Func(). Отлично! «Но правильные программисты пользуются только vim и скромными IDE». Что ж, привет вам, воображаемые мифические обычные программеры. Здесь вам ничего не угрожает, но, пожалуйста, уходя — надевайте сразу два беджика: «vim отстой» и «Я ненавижу emacs». Отлично помогает завязать разговор с «настоящими» программистами.
Итак, почему же мне не нравятся функции-члены? Всё дело в невидимых загрузках. Рассмотрим пример:
struct X {
int64_t Size;
void DoThing()
{
for (int64_t k = 0; k < Size; k++) {
// ...
}
}
};
А вот что происходит на самом деле:
void DoThing(X* that) {
for (int64_t k = 0; k < that->Size; k++) {
// ...
}
}
Создаётся впечатление, как будто мы обращаемся к локальной переменной Size, но на самом деле мы приказываем компилятору перезагружать this->Size на каждой итерации цикла. Имея дело с локальной переменной, компилятор хотел бы знать, что никто её не модифицировал; член мог измениться! Когда я на это указываю, мне обычно отвечают:
«Почему бы просто не использовать префикс для членов? m_Size?» – Да, это помогает, но всё равно не добавляет достаточной связности, как при необходимости самому печатать this->m_Size. Именно связность отучает людей от привычки что-то делать, как следует предварительно этого не обдумав (см. этот пост).
«В самом ли деле люди должны об этом задумываться?» - в некоторых предметных областях, связанных с созданием софта — обязательно. Я не знаю, в каком контексте вы работаете, но ваш ответ определённо будет зависеть от этого контекста. Я же говорю «мне это не нравится», а не «это безоговорочная истина».
«А не избавляется ли компилятор автоматически от лишней операции загрузки?» - Да, такое бывает. Может быть. Если, конечно, не случилось так, что вы вызывали функцию, а компилятор не может исключить того, что эта функция способна изменить поле. Бывает и так, что ваша функция разрослась, а кто-то жёстко запрограммировал в компиляторе распространяющийся на неё лимит, просто чтобы не иметь дел со слишком большими функциями.
«Есть ли вообще какая-то разница?» - Смотря в каком случае! Во-первых, если эта операция делается в точке, где часто совершаются вызовы, то разница может быть принципиальной. Я внёс кое-какие изменения в код работы с анимацией в одном широко используемом движке, что позволило устранить избыточные операции загрузки на всех уровнях. Это сильно повлияло на работу (~10%) и значительно улучшило генерацию кода. Во-вторых, если сделать это везде, то разница, определённо, будет. Недавно я внёс подобные изменения всего в один паттерн (из многих) в одном инструменте генерации кода, и двоичные файлы сразу стали получаться меньше 50 Кб.
Недавно я также изучил, какие есть способы дать компилятору понять, что в данной ситуации можно без опаски подвесить загрузку, то есть, выполнить её всего один раз.
void DoThing(X* that) {
for (int64_t k = 0; k < that->Size; k++) {
FuncThatIsNotInlined(k);
}
}
Абсолютно возможно, что у FuncThatIsNotInlined будет доступ к значению that, и она сможет его изменить. Например:
static X* gPtr;
void CallThing(X* that){
gPtr = that;
DoThing(that);
}
void FuncThatIsNotInlined(int64_t k)
{
if (k > 0 && gPtr)
gPtr->Size += 17;
}
Во-первых, отмечу здесь некоторые вещи, которые точно не помогают: DoThing(const X* that) не помогает, так как const легко выбрасывается при приведении и означает всего лишь, что это вы не можете её изменить — но кто-то другой вполне может. DoThing(__restrict X* that) в данном случае не поможет, поскольку __restrict всего лишь сообщает компилятору, что ваш указатель не совмещается с актуальной областью видимости. Глобально такое совмещение остаётся возможным.
В данном случае действительно помогают следующие вещи:
встраивание, поскольку в таком случае компилятор получает более полную информацию о том, что именно делает функция,
LTO/LTCG, поскольку приводит к более активному встраиванию,
Снабжение функции атрибутами, из которых компилятор узнаёт некоторую важную информацию даже о невстроенной функции,
Как в MSVC, так и в Clang/GCC предусмотрены атрибуты, которые в данном случае немного помогают.
MSVC
В MSVC есть атрибут __declspec(noalias), который можно ставить в объявлениях функций, например, вот так:
__declspec(noalias) void FuncThatIsNotInlined(int64_t k);
Об этом написано в MSDN вот здесь. Цитата:
Noalias означает, что вызов функции не изменяет видимое глобальное состояние или не ссылается на него, а изменяет только ту область памяти, на которую непосредственно направлен указатель, что следует из параметров этого указателя (опосредованность первого уровня).
Если FuncThatIsNotInlined помечена как noalias, то компилятору известно, что она может не ссылаться на глобальное состояние. В Godbolt видно это небольшое изменение в генерации кода: повторная загрузка устранена.
Clang/GCC
В Clang и GCC есть атрибуты attribute((const)) и attribute((pure)) (см. документацию). Константные функции сильнее всего похожи на функции в их математическом смысле: результат зависит только от конкретных значений параметров (то есть, не приходится иметь дела ни с разыменованием указателей, ни с глобальным состоянием), и такая функция не влияет на состояние программы. В частности, для константной функции не имеет смысла возвращать void или принимать любые параметры указателя, так как вам не разрешено их читать (…если заведомо не известно, что указатели на значения никогда не меняются). Чистые функции немного полезнее: они могут как принимать параметры указателей, так и читать значения, на которые направлены указатели.
Атрибут MSVC __declspec(noalias) невозможно напрямую сравнивать ни с одним из атрибутов Clang/GCC. Атрибут MSVC позволяет изменять любые параметры, на которые может быть направлен указатель, а у Clang/GCC таких атрибутов нет. Но noalias запрещает ссылаться на глобальное состояние, а при работе с атрибутами GCC такое не исключено — при условии, что операция чтения глобального состояния ничего не меняет.
В C23 есть два атрибута, связанных именно с этим: [[unsequenced]] и [[reproducible]]. Если их названия кажутся вам бессмысленными — будьте уверены, в том нет вашей вины. Определения всех этих атрибутов полны нюансов (не исключаю, что и я мог бы ошибиться при их использовании), они представляют собой чуть более обобщённые версии const и pure, соответственно. На мой взгляд, предложение «Unsequenced functions» от Этьена Алепинса и Йенса Гудстедта довольно легко читается.
Помогает ли это?
Если решите поэкспериментировать с этими атрибутами, то убедитесь, что в небольших изолированных случаях они действительно помогают. Но в более крупных контекстах практически невозможно обеспечить, чтобы все функции были размечены именно так. Может быть, функция затрагивает глобальное состояние и не является чистой, но вам всё равно известно, что эта конкретная сущность меняться не будет. В частности, атрибут const
из GCC налагает столько ограничений, что мне просто не попадалось случаев, в которых нельзя было бы просто сделать всю функцию inline
и поместить в заголовок. (Уверен, кому-то они попадались!)
Хотелось бы мне иметь такой волшебный атрибут, которым можно было бы «поперчить» базу кода — и решить эту проблему. Но, имея с ней дело уже достаточно долго, я пока не могу сформулировать, каков должен быть этот атрибут. Информация, которую требуется сообщить, зачастую подаётся в форме «начиная с данного конкретного момента вот эта сущность меняться не будет». А что, если в ней содержатся указатели? Включены ли её цели? До какой глубины? И т.д. Другой распространённый сценарий — «при первом вызове эта функция может менять вещи, которые затрагивает, но всё равно является идемпотентной, и повторные вызовы можно отбрасывать».
В конце концов, не перестаю убеждаться, что путь наименьшего сопротивления — просто вручную удалить избыточные операции считывания и жить дальше.
P.S. Обращаем ваше внимание на то, что у нас на сайте проходит распродажа.
Комментарии (4)
Apoheliy
25.07.2025 09:00Порекомендую подчистить терминологию: когда читаешь глаз режет.
Например, "функции экземпляров" - это что?
если смотреть в поисковик (цитата: В языке C++ функции экземпляров (шаблоны функций) создаются из шаблонов функций.) - то это в сторону шаблонов функций;
если смотреть на статью, то в ней про шаблоны вообще речи не идёт. Здесь скорее подойдёт термин "функция-член" или просто "функция класса" (хоть даже и для структуры).
В общем, консультант / редактор / рецензент - вам в помощь.
JordanCpp
25.07.2025 09:00P.S. Обращаем ваше внимание на то, что у нас на сайте проходит распродажа.
Обращаю ваше внимание, что в вашей статье, слово член встречается 5 раз:)
Пятничный юмор:)
pavlushk0
Вот прям сходу - "Довольно долго я тягался с по-настоящему глупой проблемой на C++: мне не нравятся функции экземпляров, но я вынужден их писать, чтобы программисту было хоть немного удобнее работать." = "I have been grappling with a really silly C++ problem for a long time: I don’t like member functions, but I need to write member functions to get a decent programming UX. " - Это что вообще за перевод?