Pull to refresh

Comments 66

Во втором, чистом варианте, делается предположение, что можно взять адрес переданной структуры. В первом варианте никаких дополнительных предположений о переданных параметрах не делается, он более безопасный и правильный.

Адрес переменной всегда можно взять. Варианты одинаково (не)безопасны и корректны.

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


Но здесь, очевидно, не тот случай. Второй вариант делает не больше предположений о входных параметрах, чем первый.

А вот если таргета в списке нет...

Как элемент списка может отсутствовать в списке?


В этом API предполагается что вызывающий код следит за тем, что он передает в API. В реальном коде там бы ещё стоял assert().

> Как элемент списка может отсутствовать в списке?

Да легко. Передали по ошибке элемент из другого списка.
Как элемент списка может отсутствовать в списке?

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

Представьте себе, что программа, которая итеративно обращает матрицу 64kx64k при каждом обращении к ее элементам будет делать две проверки — что индекс не отрицательный и что индекс не превосходит размера матрицы? Сколько при этом электричества и времени уйдет впустую?
UFO just landed and posted this here
Правильно, в C эти статические проверки делаются программистом на бумажке.

А теперь напоминаю вам теорему Геделя о неполноте, которая говорит нам следующее — в достаточно полной формальной системе (а Rust, или на что вы намекаете, является такой системой) всегда можно построить такое утверждение, про которое невозможно в рамках этой системы доказать, истинно оно или ложно.

Для доказательства придется выйти за рамки этой системы (взять ручку с бумажкой).

К примеру, если элементы из списка удаляются на основе внешнего ввода (которому мы доверяем), формально, глядя на код обработки этого ввода, доказать уже ничего не получтися.
UFO just landed and posted this here
Чтобы провалидировать ввод, придется таки дописать в обход списка проверку на достижение конца списка, чего мы как раз хотим избежать.

Я исхожу из предположения, что ввод приходит из внешней доверенной системы, на которую наша формальная система не распространяется.
UFO just landed and posted this here
Такой системой может являться проект сторонней команды, которая на своей стороне делает все проверки. А записан этот факт в вордовском документе канцелярским языком, потом напечатан, подписан (паркером, не ЭЦП) гендиректором компании, отсканирован и засунут в PDF

Чтобы формальное доказательство и автовывод полноценно работали, придется научить аналитиков писать требования не канцелярским языком а языком формальных систем.

В противном случае, программа будет лоскутным одеялом для верификатора — вот сюда смотри, а тут рыбу заворачивали unsafe.
UFO just landed and posted this here

В ядре нередко опускают лишние проверки в угоду скорости.

Указатели в явном виде… седой древностью потянуло :).

То же самое делается добавлением в список псевдоэлементов head и tail и хранением ссылок на них в классе списка. Особенно полезно если список двусвязный и его может потребоваться перебирать с конца.

В каком таком классе. Где тут классы?

Ну замените слово класс на слово структура мысленно, что уж вы к словам придираетесь, тем более разница в данном контексте несущественная. Идея имеет смысл. Если рассуждать в терминах ООП, то сам по себе список и его элементы — это два отдельных типа, и, если это удобнее для решения конкретной задачи, то можно создать структуру для самого списка и хранить в ней head, tail, а так же любую дополнительную статистику, относящуюся к списку, а не к конкретному элементу (например, количество элементов, имя списка).

В классе буржуазии
Думаю, более ясным описанием тут будет, что, создав указатель на head, мы как бы создали псевдоэлемент, находящийся перед head, который имеет возможность адресоваться сразу к двум элементам, т.е., это тот же prev, но без лишнего malloc'a :)
Особой магии тут нет, да выглядит элегантно. К сожалению, провернуть такой трюк в языках без указателей будет проблематично.

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

А что, в модных нынче языках есть односвязные списки?

Я уже писал ниже — когда в стандартной библиотеке нет никаких контейнеров (как это происходит в C), их делают руками. Односвязный список сделать проще всего, потому его и делают.


А в модных нынче языках в стандартную библиотеку входит много разных контейнеров на вкус и цвет.

Есть пара вопросиков, один теоретический — другой практический

Теоретический: что с эффективностью? Взятие указателей и разыменования не бесплатные. Ветвление тоже, но там то хочется верить branch-prediction не подведет

Практический: ну да ну да, конечно «особый случай с if» — это плохой код, а ломать глаза над &(*p)->next, вспоминать в каком порядке применятся операторы, и вообще осознавать на кой хрен сделано именно так — дофига хороший?

Более того, ветвление только один раз в конце алгоритма, а дополнительное разыменовывание на каждой итерации

Не совсем, все же while под капотом — тоже ветвление.

Хотя, в плане оверхеда — действительно, while и там и там, так что разницы нет
> что с эффективностью?
Ассемблерный код одинаковый для обоих вариантов: 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

Черт! Нужно себе минус поставить. Действительно, те кусочки относятся к коду после цикла. Еще и перепутал их местами, более короткий как раз у решения Линуса.

Решение Линуса покороче «в памяти», на несколько байт. Но вот по выполняемым инструкциям короче «классическое». Классическое решение тратит на 1 (иногда — 0) инструкцию меньше, чем решение Линуса. (mov-mov-ret vs [lea-]mov-mov-ret)
Теоретики, блин. Во-первых возьмите нормальный компилятор (ну или ICC).

Во-вторых, когда считаете раскладку по исполнительным блокам, то не забывайте, что что то, что вы видите в коде и то, что исполняет процессор — сильно разные вещи. Уже лет 20 как. Например "cmp+jne" — это не две инструкции, а одна (20.4: Instruction fusion), а регистровый mov — вообще не инструкция (20.13: Instructions with no latency). Не забывайте что lea считается не в ALU, кстати.

Если же брать процессоры, где инструкции-таки можно считать пальчиком, то там тоже всё не так — можно на ARM глянуть или AVR.
Мои познания ассемблера застыли на Pentium III, каюсь ;) Instruction Fusion — это уже Core.
Что почитать, чтобы в теории поднатаскаться? ;)
Во-вторых, когда считаете раскладку по исполнительным блокам, то не забывайте, что что то, что вы видите в коде и то, что исполняет процессор — сильно разные вещи
инструкции надо сначала загрузить и декодировать, а уже потом оптимизировать и исполнять. Например я натыкался на случай когда sse3 код на интринсиках, будучи скомпилированным под AVX, давал 10+% прироста к быстродействию (при той же ширине регистра). За счет избавления от тех самых mov'ов (которые «вообще не инструкции»), т.к. AVX-аналоги SSE инструкций трехаргументные.

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

UFO just landed and posted this here
Вообще-то такой код считался хорошим ещё со времён Искусства программирования и Hacker's Delight.

Просто в последние годы произошёл от медленной и невыгодной технологиии «Хуяк-и-в-продакшн» к «Ху…-релиз» (про суперсложную и невероятно медленную «Хуяк-хуяк и в продакшн» уже только аксакалы вспоминают).

Полностью согласен, но в некоторых случаях, как, например, часто используемый базовый код ядра, стоит написать "как производительнее", а не "как удобнее".

Всегда пишу вторым способом, и никаких сложностей восприятия это не создаёт, наоборот по-моему понятнее. В нём меньше сущностей поддерживается одновременно — это не только процессору хорошо, но и программисту, держащему их в голове. И проще мысленно проверить весь ход его выполнения благодаря отсутствию лишних ветвлений.
Ну и логически он ничуть не хуже первого: мы запоминаем место "сцепки" и пересоединяем её на другой элемент. А в первом варианте запоминается путь к сцепке. Просто многие видимо указатели считают какой-то непонятной магией и даже не пытаются на них навесить высокоуровенвые смысловые ярлыки, от того и проблемы.

Всё верно. Оттого и не создаёт для вас сложностей, потому что всегда так пишете, это для вас привычно :) У человека, который не писал на C много лет, от постоянных операций разыменования и взятия адреса рябит в глазах и приходится усиленно вчитываться в код, чтобы разобраться. Ну и сущностей там не особо меньше, так как "p" и "указатель на p" — это две сущности.


И нет, указатели — это не непонятная магия, а весьма простая и понятная магия вещь, но субъективно читать сложнее, если не пишешь такой код регулярно.


Да, я понимаю, что это ядро, и там подобного кода много, и люди, которые занимаются ядром, к такому привыкли. Я прекрасно понимаю, что в ядре производительность на первом месте. Но:


  1. Чтобы написать более сложный код в угоду производительности, надо сначала детально разобраться, сравнить и обосновать, что это будет действительно более производительно, и что этот эффект будет существенным, чтобы принести больший value, чем потребовать в дальнейшем поддержки. А то может получиться, что оптимизации компилятора сведут все старания на нет. Чем проще и прямолинейнее код, тем проще компилятору его оптимизировать, и, по итогу, он может оказаться более производительным, чем хитрозакрученная реализация. А может вообще этот код будет использоваться, условно, раз в год на списках из 5 элементов, и никакого ускорения никто в жизни не заметит, зато накрученная сложность увеличивает вероятность багов. Надо разумно подходить и без необходимости не усложнять код.
  2. Для многих элегантность определяется простотой понимания сторонним человеком без глубокого знания кода и, соответственно, стоимостью дальнейшей поддержки. Чтобы беглый взгляд на код давал возможность быстро и без особых усилий понять, что здесь происходит, и починить баг или расширить функциональность в связи с новыми требованиями.

Как известно, единственной измеримой метрикой качества кода является количество WTF в минуту на ревью :)

Согласен.
Как «скорее читатель, чем писатель», могу добавить, что хорошей идеей является написание комментариев для Doxygen. Желетельно, с разбиением на группы.

Интересно, что если написать один единственный тест на удаление из середины, и прогнать его с branch coverage, то первая реализация укажет на то, что покрытие явно недостаточно, а вот вторая нет.

UFO just landed and posted this here
так и напишем в резюме: «переписывал код за самим Линусом Торвальдсом»
Связные списки, трюки с указателями и хороший вкус
Хороший вкус — не использовать списки без крайней необходимости.
Ну или использовать готовые.
Кстати, в каких ситуациях может понадобиться писать свою реализацию списка? Хотя бы теоретическую ситуацию, мне пока не хватает опыта сразу придумать.
На собеседовании, например

Когда пишешь какие-нибудь lock-free алгоритмы. Там односвязные списки практически единственное что можно использовать.

Пожалуйста, посоветуйте что почитать на тему 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 постоянно этим занимаются. Потому что


  1. В стандартной библиотеке нет связного списка.
  2. Нет общепринятого менеджера зависимостей, через который можно было бы притащить библиотеку с реализацией списков.

В С++, хоть и есть стандартный list, но он тормознутый и, если вам надо что-то чуть более навороченное, чем самый простой список, то придется писать самим.

Если копнуть вопрос глубже, то связные списки не нужны примерно никогда, вместо них предпочтительнее реаллоцируемые массивы (типа std::vector). Но самодельный vector сделать гораздо сложнее чем самодельный связный список, поэтому в C все делают связные списки.

UFO just landed and posted this here
Я понял свою ошибку в этом вопросе — даже читая в тексте статьи что код на С, я по привычке размышляю в парадигме С++.
Код Торвальдса более элегантный — спору нет.
Но менее понятный при беглом прочтении.
«элегантность» кода должна состоять как раз в понятности при беглом прочтении

В "элегантном" коде есть серьёзный недостаток:


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» для переменной, объявленной как **, это перебор. В конце концов, не просто так венгерка отмерла
Sign up to leave a comment.

Articles