Название имплементации и название результата

Original author: Jonathan Müller
  • Translation


Я хотел написать этот пост ещё в июле, но никак не мог, о ирония, решить, как его назвать. Удачные термины пришли мне в голову только после доклада Кейт Грегори на CppCon, и теперь я наконец могу рассказать вам, как не надо называть функции.


Бывают, конечно, названия, которые вообще не несут информации, типа int f(int x). Ими пользоваться тоже не надо, но речь не о них. Порой бывает, что вроде бы и информации в названии полно, но пользы от неё абсолютно никакой.


Пример 1: std::log2p1()


В C++20 в заголовок добавили несколько новых функций для битовых операций, среди прочих std::log2p1. Выглядит она вот так:


int log2p1(int i)
{
    if (i == 0)
        return 0;
    else
        return 1 + int(std::log2(x)); 
}

То есть для любого натурального числа функция возвращает его двоичный логарифм плюс 1, а для 0 возвращает 0. И это не школьная задачка на оператор if/else, это действительно полезная вещь — минимальное число бит, в которое поместится данное значение. Вот только догадаться об этом по названию функции практически невозможно.


Пример 2: std::bless()


Сейчас будет не про названия


Небольшое отступление: в С++ арифметика указателей работает только с указателями на элементы массива. Что, в принципе, логично: в общем случае набор соседних объектов неизвестен и “в десяти байтах справа от переменной i” может оказаться что угодно. Это однозначно неопределённое поведение.


int obj = 0;
int* ptr = &obj;

++ptr; // Неопределённое поведение

Но такое ограничение объявляет неопределённым поведением огромное количество существующего кода. Например, вот такую упрощённую имплементацию std::vector<T>::reserve():


void reserve(std::size_t n)
{
    // выделяем память под наши объекты
    auto new_memory = (T*) ::operator new(n * sizeof(T));

    // переносим их туда
    …

    // обновляем буфер
    auto size = this->size();
    begin_ = new_memory;            // Неопределённое поведение
    end_   = new_memory + size;     // Ещё раз неопределённое поведение
    end_capacity_ = new_memory + n; // и ещё раз
}

Мы выделили память, перенесли все объекты и теперь пытаемся убедиться, что указатели указывают куда надо. Вот только последние три строчки неопределены, потому что содержат арифметические операции над указателями вне массива!


Разумеется, виноват тут не программист. Проблема в самом стандарте C++, который объявляет неопределённым поведением этот очевидно разумный кусок кода. Поэтому P0593 предлагает исправить стандарт, добавив некоторым функциям (вроде ::operator new и std::malloc) способность создавать массивы по мере необходимости. Все созданные ими указатели будут магическим образом становиться указателями на массивы, и с ними можно будет совершать арифметические операции.


Всё ещё не про названия, потерпите секундочку.


Вот только иногда операции над указателями требуются при работе с памятью, которую не выделяла одна из этих функций. Например, функция deallocate() по сути своей работает с мёртвой памятью, в которой вообще нет никаких объектов, но всё же должна сложить указатель и размер области. На этот случай P0593 предлагал функцию std::bless(void* ptr, std::size_t n) (там была ещё другая функция, которая тоже называется bless, но речь не о ней). Она не оказывает никакого эффекта на реально существующий физический компьютер, но создаёт для абстрактной машины объекты, которые разрешили бы использовать арифметику указателей.


Название std::bless было временным.


Так вот, название.


В Кёльне перед LEWG поставили задачу — придумать для этой функции название. Были предложены варианты implicitly_create_objects() и implicitly_create_objects_as_needed(), потому что именно это функция и делает.


Мне эти варианты не понравились.


Пример 3: std::partial_sort_copy()


Пример взят из выступления Кейт


Есть функция std::sort, которая сортирует элементы контейнера:


std::vector<int> vec = {3, 1, 5, 4, 2};
std::sort(vec.begin(), vec.end());
// vec == {1, 2, 3, 4, 5}

Ещё есть std::partial_sort, которая сортирует только часть элементов:


std::vector<int> vec = {3, 1, 5, 4, 2};
std::partial_sort(vec.begin(), vec.begin() + 3, vec.end());
// vec == {1, 2, 3, ?, ?} (либо ...4,5, либо ...5,4)

И ещё есть std::partial_sort_copy, которая тоже сортирует часть элементов, но при этом старый контейнер не меняет, а переносит значения в новый:


const std::vector<int> vec = {3, 1, 5, 4, 2};
std::vector<int> out;
out.resize(3);
std::partial_sort_copy(vec.begin(), vec.end(),
                       out.begin(), out.end());
// out == {1, 2, 3}

Кейт утверждает, что std::partial_sort_copy — так себе название, и я с ней согласен.


Название имплементации и название результата


Ни одно из перечисленных названий не является, строго говоря, неверным: они все прекрасно описывают то, что делает функция. std::log2p1() действительно считает двоичный логарифм и прибавляет к нему единицу; implicitly_create_objects() имплицитно создаёт объекты, а std::partial_sort_copy() частично сортирует контейнер и копирует результат. Тем не менее, все эти названия мне не нравятся, потому что они бесполезны.


Ни один программист не сидит и не думает “вот бы мне взять двоичный логарифм, да прибавить бы к нему единицу”. Ему нужно знать, во сколько бит поместится данное значение, и он безуспешно ищет в доках что-нибудь типа bit_width. К моменту, когда до пользователя библиотеки доходит, при чём тут вообще двоичный логарифм, он уже написал свою имплементацию (и, скорее всего, пропустил проверку для ноля). Даже если каким-то чудом в коде оказалось std::log2p1, следующий, кто увидит этот код, опять должен понять, что это и зачем оно нужно. У bit_width(max_value) такой проблемы бы не было.


Точно так же никому не надо “имплицитно создавать объекты” или “проводить частичную сортировку копии вектора” — им нужно переиспользовать память или получить 5 наибольших значений в порядке убывания. Что-то типа recycle_storage() (что тоже предлагали в качестве названия std::bless) и top_n_sorted() было бы гораздо понятнее.


Кейт использует термин название имплементации для std::partial_sort_copy(), но он прекрасно подходит и к двум другим функциям. Имплементацию их названия действительно описывают идеально. Вот только пользователю нужно название результата — то, что он получит, вызвав функцию. До её внутреннего устройства ему нет никакого дела, он просто хочет узнать размер в битах или переиспользовать память.


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


Звучит банально, да. Но, судя по std::log2p1(), это далеко не всем очевидно. К тому же порой всё не так просто.


Пример 4: std::popcount()


std::popcount(), как и std::log2p1(), в C++20 предлагается добавить в <bit>. И это, разумеется, чудовищно плохое название. Если не знать, что эта функция делает, догадаться невозможно. Мало того, что сокращение сбивает с толку (pop в названии есть, но pop/push тут ни при чём) — расшифровка population count (подсчёт населения? число популяций?) тоже не помогает.


С другой стороны, std::popcount() идеально подходит для этой функции, потому что она вызывает ассемблерную инструкцию popcount. Это не то что название имплементации — это полное её описание.


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


Кстати, хороший вопрос: придумывать ли названия, удобные для новичков, или оставить привычные для олдфагов?


Хэппи-энд?


P1956 предлагает переименовать std::log2p1() в std::bit_width(). Это предложение, вероятно, будет принято в C++20. std::ceil2 и std::floor2 тоже переименуют, в std::bit_ceil() and std::bit_floor() соответственно. Их старые названия тоже были не очень, но по другим причинам.


LEWG в Кёльне не выбрала ни implicitly_create_objects[_as_needed], ни recycle_storage в качестве названия для std::bless. Эту функцию решили вообще не включать в стандарт. Тот же эффект может быть достигнут эксплицитным созданием массива байтов, поэтому, дескать, функция не нужна. Мне это не нравится, потому что вызов std::recycle_storage() был бы читаемее. Другая std::bless() всё ещё существует, но теперь называется start_lifetime_as. Это мне нравится. Она должна войти в C++23.


Разумеется, std::partial_sort_copy() уже не переименуют — под этим названием она вошла в стандарт ещё в 1998. Но хотя бы std::log2p1 исправили, и то неплохо.


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

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 23

    0
    Если насчет log2p1 полностью согласен про неочевидность, то вот partial_sort_copy мне кажется довольно просто и понятно, понятнее прочих названий. Но это конечно только мое мнение.
      +1
      А почему не тот же partial_sort(...), но с дополнительным аргументом, куда копировать результат. Там конечно уже совсем страшный список аргументов получается, хотелось бы (мне) видеть что-то типа:
      partial_sort(src_begin, src_end, copy_to(dest_begin, dest_end));
        0
        Ваш вариант тоже вполне хороший, как по мне. Мне не понравились предложенные варианты автора: recycle_storage() — как-то не так очевидно, а top_n_sorted() — исключительный узкий случай, у partial_sort() или partial_sort_copy() — больше вариантов применения, кроме нескольких наибольших элементов. Как минимум, еще варианты наименьшие элементы, или вообще где-то из середины по какому-то своему хитрому условию.
          0
          т.е. сначала что-то где-то отсортировать, а потом применить копирование? боюсь, это будет уже нет так рационально…
            0
            copy_to() это же не сама функция копирования. Двух аргументов (first, last) ей было бы явно недостаточно, разве что если вот так как-нибудь (как по мне красивее, но для оптимизации с ходу не вижу вариантов):
            partial_sort(src_begin, src_end).copy_to(dest_begin, dest_end);
            
            В моем же случае это может быть например объект, содержащий этот самый интервал, который передается как аргумент в partial_sort().
        0
        уже не переименуют — под этим названием она вошла в стандарт ещё в 1998


        можно добавить название, не обязательно переименовывать
          +1
          > Небольшое отступление: в С++ арифметика указателей работает только с указателями на элементы массива.

          Consider the following natural C program:

          struct X { int a, b; };
          X *make_x() {
          X *p = (X*)malloc(sizeof(struct X));
          p->a = 1;
          p->b = 2;
          return p;
          }

          When compiled with a C++ compiler, this code has undefined behavior, because p->a attempts to write to an int subobject of an X object, and this program never created either an X object nor an int subobject.


          Серьёзно? С какого момента это появилось в С++ и зачем?
            +1

            Что именно "это"? Создание объектов типа struct подразумевает вызов конструкторов (которые есть, даже если их "не видно"), чего malloc не делает, поэтому в C++ коде нужно пользоваться new. Можно вызывать и malloc, но тогда нужно будет все равно создать в этом месте объект через placement new. Для многих это является сюрпризом, но C и C++ — разные языки, хотя и с похожим синтаксисом и высокой степенью совместимости.

              0
              Создание объектов типа struct подразумевает вызов конструкторов

              На сколько я помню это только для неPOD типов.
              в С++ арифметика указателей работает только с указателями на элементы массива.
              это, как и пример из статьи, вообще как-то дико звучит. С какой стати?

              PS.
              Что именно «это»?
              Давайте тон попроще а? В моем сообщении вполне выделены два пункта, не надо тут делать недовольное лицо.
                0
                На сколько я помню это только для неPOD типов.

                en.cppreference.com/w/cpp/language/object

                Objects are created by definitions, new-expressions, throw-expressions, when changing the active member of a union, and where temporary objects are required.

                malloc не делает ничего из этого, а никакого исключения для POD типов не предусмотрено.

                это, как и пример из статьи, вообще как-то дико звучит. С какой стати?

                С какой стати что именно? Что конкретно вы хотите делать с помощью арифметики указателей?

                P.S. На тему арифметических операций с указателями, выдержка из стандарта C++11, параграф 5.7:

                When an expression that has integral type is added to or subtracted from a pointer, the result has the type of the pointer operand. If the pointer operand points to an element of an array object, and the array is large enough, the result points to an element offset from the original element such that the difference of the subscripts of the resulting and original array elements equals the integral expression. In other words, if the expression P points to the i-th element of an array object, the expressions (P)+N (equivalently, N+(P)) and (P)-N (where N has the value n) point to, respectively, the i + n-th and i − n-th elements of the array object, provided they exist. Moreover, if the expression P points to the last element of an array object, the expression (P)+1 points one past the last element of the array object, and if the expression Q points one past the last element of an array object, the expression (Q)-1 points to the last element of the array object. If both the pointer operand and the result point to elements of the same array object, or one past the last element of the array object, the evaluation shall not produce an overflow; otherwise, the behavior is undefined.

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

                PS. В общем повода дле беспокойства нет как я понял.

                Смотря что вы пишете и как.

                Давайте тон попроще а?

                А давайте без давайте. Я и так по сути роюсь в референсах и стандартах за вас, а вы только задаете вопросы в стиле «что это за фигня, с какой стати». Недовольное лицо тут пока только у вас.
                  0
                  А давайте без давайте. Я и так по сути роюсь в референсах и стандартах за вас, а вы только задаете вопросы в стиле «что это за фигня, с какой стати». Недовольное лицо тут пока только у вас.


                  Могли бы себя и не утруждать.
                  От ваших референсов если честно вообще толку ноль, больше похоже на «на утрись, в стандарте так написано». Сами же вы похоже не понимаете зачем это там написано. Так что пожалуйста оставьте свой аггресивный тон себе, и возможно вашим коллегам, нет никакого сымысла делать это в публичном месте.

                  А я уже и без вашей помощи нашел, что существует en.cppreference.com/w/cpp/numeric/bit_cast предназначенный для решения задач, которые раньше решались через reinterpret_cast.

                  Недовольное лицо тут пока только у вас.

                  Не надо приписывать свои эмоции мне, я лишь выражал удивление и восторг.

                    0

                    Я как раз понимаю. Кстати, если вы раньше использовали для решения таких задач reinterpret_cast вместо memcpy, то вы плодили UB. Впрочем, ладно.

                      0
                      Если рабраться, gist.github.com/shafik/848ae25ee209f698763cffee272a58f8, то UB возникает в некоторых случаях, когда оптимизатор делает лишнии оптимизации.

                      Слишком уж много кода зависит от reinterpret_cast, и тут дело не в том что мы используем, а все так писали достаточно долго.

                      А вот пример из статьи несколько преувеличен, адресная арифметика работает в С++ как и положено, и даже в примере автора не будет UB, потому что у него лишь указатель на
                      expression (P)+1 points one past the last element of the array object,

                      Если бы всё было так плохо как он описал, то как он предлагает реализовавать аллокаторы для стандартных контейнеров? Там на сколько я знаю всё ещё такой интерфейс
                      T* allocate(std::size_t n)
                        0
                        Ну что значит «лишние оптимизации». Эти оптимизации компилятор как раз и имеет право делать благодаря правилам алиасинга, которым программист должен следовать, для этого эти правила и придуманы — чтобы у компилятора было как раз больше простора для оптимизаций. Если даже сейчас компилятор сохраняет код работоспособным, то это не значит, что в следующей версии не добавятся какие-то оптимизации, которые поломают код с некорректными reinterpret_cast (а варианты его корректного использования можно буквально пересчитать по пальцам). Для сохранения работоспособности кода, который «так писали достаточно долго», сделали костыли типа -fno-strict-aliasing, но это именно что костыли. Что касается аллокаторов, то они AFAIK реализованы как раз через placement new, который имеет интерфейс типа new(ptr) T, то есть может конструировать объект по «готовому» адресу — сначала делается ptr на буфер нужного размера через тот же malloc, а потом по этому ptr создаётся объект.

                        P.S. Я помню в linux kernel была забавная история, когда там memcpy был реализован через макрос(!), который «для ускорения процесса» приводил буферы к long* и копировал в цикле «аж по 4 байта (а то и по 8)», что приводило к реордерингам вызова этого так называемого «memcpy», он начинал вызываться до кода, который следовал по тексту ранее его и должен был по идее что-то менять в копируемых структурах, и начинался адок. Почти #define true false :)
                          0
                          Если рабраться, gist.github.com/shafik/848ae25ee209f698763cffee272a58f8, то UB возникает в некоторых случаях, когда оптимизатор делает лишнии оптимизации.

                          UB — это не то, что может возникать из-за оптимизаций. UB — это свойство кода и от уровня оптимизаций не зависит.


                          Слишком уж много кода зависит от reinterpret_cast, и тут дело не в том что мы используем, а все так писали достаточно долго.

                          Это вы ещё на union-style cast не смотрели. Мы в соседнем треде, кстати, так и не смогли разобраться, является ли union-style cast с одним из полей, являющимся char*, UB или таки нет. Я топлю за то, что да, является.


                          Скрытый текст

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

                            0
                            является ли union-style cast с одним из полей, являющимся char*, UB или таки нет

                            В C++ — однозначно является (а почему нет-то?), в C — нет.

                            слишком много мин на каждом шагу

                            Чем больше свободы — тем больше ответственности. И наоборот, соответственно, хехе.
                              0

                              Я боюсь с мобильника открывать тот тред, но если вам вдруг интересен контекст, сделайте поиск по CharWrapper на этой странице, попадете в соответствующую ветку.

                                0
                                Я так понимаю что там пытаются доказать что если одним из элементов union является char, то к нему можно получат доступ всегда, но ИМХО это не так — в стандарте четко сказано, что «at most one of the non-static data members can be active at any time, that is, the value of at most one of the non-static data members can be stored in a union at any time». То есть в какое поле ты последним туда пишешь, только из такого поля ты и имеешь право читать, пока не запишешь в другое (хотя компиляторы обычно все-таки генерируют «дружественный» в этом смысле код). Натягивать сюда алиасинг я бы не стал, он тут ИМХО не при чем, так как для случая union написано, так сказать, более специализированное правило.
                              0
                              UB — это не то, что может возникать из-за оптимизаций. UB — это свойство кода и от уровня оптимизаций не зависит.

                              В данном случае код вполне корректный, единственная проблема — это отсутствие проверки выравнивания. Но мы захотели сделать наш код ещё быстрее, в итоге комитет выдал перлы уровня «вызывайте мемкопи, который оптимизатором будет отбрасываться и заменятся на прямое обращение к памяти»
                    0

                    Всё становится куда интереснее, когда вы дёргаете не malloc, а, например, mmap.

                      0
                      Не отрицаю!
                  0
                  Справедливости ради, хотелось бы сказать, что по поводу log2p1(), popcount() и прочих функций из этой группы была большущая дискуссия на последнем собрании C++ комитета в Белфасте. Внутри коитета аудитория существенно разделилась. В силу отсутствия консенсуса было принято волевое решение. И если мне не изменяет память, log2p1 таки переименовали в bit_width.
                    0

                    Интересно, почему не count_set_bits вместо popcount?

                  Only users with full accounts can post comments. Log in, please.