Comments 66
А вот если таргета в списке нет...
Как элемент списка может отсутствовать в списке?
В этом API предполагается что вызывающий код следит за тем, что он передает в API. В реальном коде там бы ещё стоял assert().
Представьте себе, что программа, которая итеративно обращает матрицу 64kx64k при каждом обращении к ее элементам будет делать две проверки — что индекс не отрицательный и что индекс не превосходит размера матрицы? Сколько при этом электричества и времени уйдет впустую?
А теперь напоминаю вам теорему Геделя о неполноте, которая говорит нам следующее — в достаточно полной формальной системе (а Rust, или на что вы намекаете, является такой системой) всегда можно построить такое утверждение, про которое невозможно в рамках этой системы доказать, истинно оно или ложно.
Для доказательства придется выйти за рамки этой системы (взять ручку с бумажкой).
К примеру, если элементы из списка удаляются на основе внешнего ввода (которому мы доверяем), формально, глядя на код обработки этого ввода, доказать уже ничего не получтися.
Я исхожу из предположения, что ввод приходит из внешней доверенной системы, на которую наша формальная система не распространяется.
Чтобы формальное доказательство и автовывод полноценно работали, придется научить аналитиков писать требования не канцелярским языком а языком формальных систем.
В противном случае, программа будет лоскутным одеялом для верификатора — вот сюда смотри, а тут
В ядре нередко опускают лишние проверки в угоду скорости.
То же самое делается добавлением в список псевдоэлементов head и tail и хранением ссылок на них в классе списка. Особенно полезно если список двусвязный и его может потребоваться перебирать с конца.
В каком таком классе. Где тут классы?
Ну замените слово класс на слово структура мысленно, что уж вы к словам придираетесь, тем более разница в данном контексте несущественная. Идея имеет смысл. Если рассуждать в терминах ООП, то сам по себе список и его элементы — это два отдельных типа, и, если это удобнее для решения конкретной задачи, то можно создать структуру для самого списка и хранить в ней head, tail, а так же любую дополнительную статистику, относящуюся к списку, а не к конкретному элементу (например, количество элементов, имя списка).
К счастью, в языках без указателей обычно не нужно в тысячный раз писать самодельную реализацию базовых структур данных, а достаточно воспользоваться входящими в стандартную библиотеку.
А что, в модных нынче языках есть односвязные списки?
Теоретический: что с эффективностью? Взятие указателей и разыменования не бесплатные. Ветвление тоже, но там то хочется верить branch-prediction не подведет
Практический: ну да ну да, конечно «особый случай с if» — это плохой код, а ломать глаза над &(*p)->next, вспоминать в каком порядке применятся операторы, и вообще осознавать на кой хрен сделано именно так — дофига хороший?
Более того, ветвление только один раз в конце алгоритма, а дополнительное разыменовывание на каждой итерации
Ассемблерный код одинаковый для обоих вариантов: godbolt.org/z/676Mn4
Да где же:
Вариант Линуса:
mov rax, QWORD PTR [rsi+8]
mov QWORD PTR [rdx+8], rax
Традиционный:
lea rdi, [rdx+8]
Не очень хорошее сравнение из-за различий имен меток: https://godbolt.org/z/b4xGv4
Причем, что можно заметить, в традиционном варианте даже нет финальной специальной обработки, все различие между листингами только внутри цикла.
все различие между листингами только внутри цикла.
Где?! Различие только на выходе из функции, в одну инструкцию. Цикл — дословно один и тот же.
mov rax, QWORD PTR [rdi]
cmp rax, rsi
je .L2
.L4:
mov rdx, rax
mov rax, QWORD PTR [rax+8]
cmp rsi, rax
jne .L4
Черт! Нужно себе минус поставить. Действительно, те кусочки относятся к коду после цикла. Еще и перепутал их местами, более короткий как раз у решения Линуса.
Во-вторых, когда считаете раскладку по исполнительным блокам, то не забывайте, что что то, что вы видите в коде и то, что исполняет процессор — сильно разные вещи. Уже лет 20 как. Например "
cmp+jne
" — это не две инструкции, а одна (20.4: Instruction fusion), а регистровый mov
— вообще не инструкция (20.13: Instructions with no latency). Не забывайте что lea
считается не в ALU, кстати.Если же брать процессоры, где инструкции-таки можно считать пальчиком, то там тоже всё не так — можно на ARM глянуть или AVR.
Что почитать, чтобы в теории поднатаскаться? ;)
Во-вторых, когда считаете раскладку по исполнительным блокам, то не забывайте, что что то, что вы видите в коде и то, что исполняет процессор — сильно разные вещиинструкции надо сначала загрузить и декодировать, а уже потом оптимизировать и исполнять. Например я натыкался на случай когда sse3 код на интринсиках, будучи скомпилированным под AVX, давал 10+% прироста к быстродействию (при той же ширине регистра). За счет избавления от тех самых mov'ов (которые «вообще не инструкции»), т.к. AVX-аналоги SSE инструкций трехаргументные.
Я мимо шёл. На ARMv8 clang вариант Линуса короче на 5 инструкций: https://godbolt.org/z/xqbarc
Согласен. Код должен писаться для людей, а не для машины. Чем проще код для восприятия и чтения, тем лучше при прочих равных (если не страдает эффективность и корректность). Так дешевле поддерживать, меньше багов будет.
Просто в последние годы произошёл от медленной и невыгодной технологиии «Хуяк-и-в-продакшн» к «Ху…-релиз» (про суперсложную и невероятно медленную «Хуяк-хуяк и в продакшн» уже только аксакалы вспоминают).
Полностью согласен, но в некоторых случаях, как, например, часто используемый базовый код ядра, стоит написать "как производительнее", а не "как удобнее".
Всегда пишу вторым способом, и никаких сложностей восприятия это не создаёт, наоборот по-моему понятнее. В нём меньше сущностей поддерживается одновременно — это не только процессору хорошо, но и программисту, держащему их в голове. И проще мысленно проверить весь ход его выполнения благодаря отсутствию лишних ветвлений.
Ну и логически он ничуть не хуже первого: мы запоминаем место "сцепки" и пересоединяем её на другой элемент. А в первом варианте запоминается путь к сцепке. Просто многие видимо указатели считают какой-то непонятной магией и даже не пытаются на них навесить высокоуровенвые смысловые ярлыки, от того и проблемы.
Всё верно. Оттого и не создаёт для вас сложностей, потому что всегда так пишете, это для вас привычно :) У человека, который не писал на C много лет, от постоянных операций разыменования и взятия адреса рябит в глазах и приходится усиленно вчитываться в код, чтобы разобраться. Ну и сущностей там не особо меньше, так как "p" и "указатель на p" — это две сущности.
И нет, указатели — это не непонятная магия, а весьма простая и понятная магия вещь, но субъективно читать сложнее, если не пишешь такой код регулярно.
Да, я понимаю, что это ядро, и там подобного кода много, и люди, которые занимаются ядром, к такому привыкли. Я прекрасно понимаю, что в ядре производительность на первом месте. Но:
- Чтобы написать более сложный код в угоду производительности, надо сначала детально разобраться, сравнить и обосновать, что это будет действительно более производительно, и что этот эффект будет существенным, чтобы принести больший value, чем потребовать в дальнейшем поддержки. А то может получиться, что оптимизации компилятора сведут все старания на нет. Чем проще и прямолинейнее код, тем проще компилятору его оптимизировать, и, по итогу, он может оказаться более производительным, чем хитрозакрученная реализация. А может вообще этот код будет использоваться, условно, раз в год на списках из 5 элементов, и никакого ускорения никто в жизни не заметит, зато накрученная сложность увеличивает вероятность багов. Надо разумно подходить и без необходимости не усложнять код.
- Для многих элегантность определяется простотой понимания сторонним человеком без глубокого знания кода и, соответственно, стоимостью дальнейшей поддержки. Чтобы беглый взгляд на код давал возможность быстро и без особых усилий понять, что здесь происходит, и починить баг или расширить функциональность в связи с новыми требованиями.
Как известно, единственной измеримой метрикой качества кода является количество WTF в минуту на ревью :)
Интересно, что если написать один единственный тест на удаление из середины, и прогнать его с branch coverage, то первая реализация укажет на то, что покрытие явно недостаточно, а вот вторая нет.
Связные списки, трюки с указателями и хороший вкусХороший вкус — не использовать списки без крайней необходимости.
Кстати, в каких ситуациях может понадобиться писать свою реализацию списка? Хотя бы теоретическую ситуацию, мне пока не хватает опыта сразу придумать.
Когда пишешь какие-нибудь lock-free алгоритмы. Там односвязные списки практически единственное что можно использовать.
Если не привязываться к конкретному языку программирования то вот что нагуглилось
https://www.cs.tau.ac.il/~shanir/concurrent-data-structures.pdf
Все структуры данных (стеки, очереди, хешмапы, ...) используют linked list как деталь реализации
Если привязываться к языку то можете посмотреть реализацию в c# или java ConcurrentЧтоУгодно
Причина в том что lock-free фактчиески строится на одной команде cpu CAS https://en.wikipedia.org/wiki/Compare-and-swap
и это значит что мы можем поддерживать связный список в консистентном состоянии следующим образом:
- прочитать значение по указателю
- сделать операцию иммутабельным образом над значением по указателю (классическим методом)
- при помощи CAS заменить указатель на старый объект на новый. Если не получилось (кто-то другой влез перед нами), goto шаг 1.
При обработке пакетов данных надо было их удалять по мере собирания по порядку и по мере таймаута. Можно организовать их в 2 переплетенных списка на одних и тех же данных. Готовой реализации такой штуки, естественно, нет. Можно было бы, конечно, делать 2 различных списка указателей, но там расход памяти больше и лишние разыменовывания указателей.
Еще пример, если вы знаете, что у вас в списке не будет более N элементов, но он часто часто меняется. Тогда можно вместо указателей использовать индексы в массиве и, во первых, держать данные в куче, а во вторых избавиться от всех ненужных аллокаций.
Грубо говоря, когда вам нужно что-то нестандартное.
в каких ситуациях может понадобиться писать свою реализацию списка?
В программах на C постоянно этим занимаются. Потому что
- В стандартной библиотеке нет связного списка.
- Нет общепринятого менеджера зависимостей, через который можно было бы притащить библиотеку с реализацией списков.
В С++, хоть и есть стандартный list, но он тормознутый и, если вам надо что-то чуть более навороченное, чем самый простой список, то придется писать самим.
Но менее понятный при беглом прочтении.
В "элегантном" коде есть серьёзный недостаток:
void remove_elegant(IntList *l, IntListItem *target)
{
IntListItem **p = &l->head;
while ((*p) != target) {
p = &(*p)->next;
}
*p = target->next;
}
Видите? Вот это "p
"! Это очень плохо! Назовите его по-человечески, разбейте трёхэтажное разыменование на несколько стадий с нормально названными переменными и количество WTF даже от не могущих в Си значительно сократится.
Например так:
void remove_elegant(IntList *l, IntListItem *target)
{
IntListItem **ptr2current_ptr = &l->head;
while ((*ptr2current_ptr ) != target) {
ptr2current_ptr = &((*ptr2current_ptr )->next);
}
*ptr2current_ptr = target->next;
}
Я не выбрал ptr2ptr2current
, потому что это 1) тавтологически повторяет тип 2) мы на самом деле не используем IntListItem current
, только указатели на, они для нас самодостаточны.
Поддержу. Осмысленные имена переменных вместо a, b, p и т.п. — одно из отличий production-ready промышленного кода от лабы первокурсника. Ну и "l" в "list" неплохо было бы переименовать до кучи.
Я не выбрал ptr2ptr2current, потому что это 1) тавтологически повторяет типкороткое не говорящее имя переменной как раз отличный признак того, что назначение переменной проще описывается её типом (в объявлении) и контекстом. Префикс «ptr2ptr» для переменной, объявленной как **, это перебор. В конце концов, не просто так венгерка отмерла
Люблю этот трюк. Вот несколько задач на leetcode, которые я решил с его помощью:
701. Insert into a Binary Search Tree
1474. Delete N Nodes After M Nodes of a Linked List
1669. Merge In Between Linked Lists
Связные списки, трюки с указателями и хороший вкус