Признаки устаревшего языка (ссылка на статью)


Комментарии:

2021-01-16 alextretyak

На первый взгляд эта тема действительно очень актуальна и пример с устаревшим самолётом звучит крайне убедительно (можно ещё добавить к ‘последние достижения науки и техники’ в скобочках: интернет на борту и большой LCD тач экран на спинке каждого кресла самолёта).

Но.
Если погрузиться в детали, то не всё так однозначно/просто.

Разберу конкретные признаки, приведённые в статье:
оператор «goto»;
В целом согласен, т.к. достаточно break/continue с меткой, а также конструкции defer (как в Go и Swift). Все остальные варианты использования goto порождают спагетти-код.

такая обработка исключений, которая ещё хуже «goto», когда исключение неизвестно где возникает и неизвестно куда передаёт управление;
Очень больная/большая тема.
По вопросу исключений я и сам менял свою позицию, но решить этот вопрос одними рассуждениями — невозможно. Требуется проанализировать много-много реальных примеров, практического кода, самого разного, на разных языках программирования, с разными способами обработки ошибок, чтобы хоть как-то приблизиться к окончательному решению по данному вопросу. Лично я пока остановился на «устаревшей» модели, принятой в Python и C++, так как ничего реально лучшего (не ‘на словах’/‘в теории’ (см. моё предложение), а на практике) я посоветовать не могу.

постфиксные операции «++» и «--», которые для большинства — загадочны;
Ещё один спорный момент. [В Swift 3 вообще убрали «++» и «--».]
Но если я правильно понимаю автора, то против префиксных «++» и «--» он ничего не имеет? Тогда готов согласиться, хотя и считаю запись
i++
красивее
++i
.

приведения типов, влекущие «тайное» изменения значения;
Не совсем понятно, о каком приведении типов идёт речь. Лично я считаю разумным отказ от неявного приведения типов в большинстве случаев, но оставить возможность явного приведения типов (например, строку в число и наоборот).

нулевой указатель
Также непонятно, о чём речь. Автор против nullable-типов? Или имеется в виду решение этой проблемы в стиле Kotlin (https://kotlinlang.ru/docs/reference/null-safety.html), с обязательной проверкой на null?

возможность присвоить неинициализированное значение
А что плохого, если язык поддерживает явное указание создания неинициализированной переменной, как например D:
https://dlang.org/spec/declaration.html#void_init:
int x = void;
Это может быть полезно для создания очень больших массивов, в которых реально использоваться будет лишь небольшая часть элементов.

визуальный мусор типа «begin» и «end», особенно ЗАГЛАВНЫМИ буквами;
Согласен.
[Сюда же [в визуальный мусор] можно добавить обязательные точки с запятой в конце строк.]

контроля возможного переполнения при арифметических операциях;
Уже обсуждалось здесь.
Как итог: пока процессоры не будут поддерживать генерацию исключения при переполнении аппаратно и без оверхеда, не стоит ожидать такого контроля {} в языках программирования, ориентированных на высокую производительность.

приоритетов операций а-ля Lisp или Forth;
контроля границ массивов а-ля Си;
возможности вернуть из функций объекты как скалярные, так и не скалярные, как фиксированного, так и переменного размера;
функций — объектов первого класса;
оператора «for each»;
вывода типов;
Согласен.

обращения по абсолютным адресам
синтаксис, прогибающий под себя программистов, а-ля Forth;
программирования в стиле доказательств;
зрительных ориентиров в тексте, позволяющих отличить операции от операндов;
Не [совсем] понял, о чём это.

2021-01-16 Автор сайта

то против префиксных «++» и «--» он ничего не имеет?
Я протестую не против синтаксиса — префиксного или постфиксного, а против семантики постфиксных «++» и «--».

о каком приведении типов идёт речь
Например, присвоение переменной типа int значения типа long, float или double часто делается с искажением значения. В таких случаях надо или явно «убивать» лишние биты значения, либо должно быть реагирование на ошибку, если что-то теряем в лишних битах:
char a = выбрать 8 младших битов (9876543210);  // явное обрезание лишних битов
char b =  9876543210;                           // неявная потеря битов. Если потеря и вправду
                                                // случается, возбуждаем исключение

Автор против nullable-типов?
Да. Адрес, указывающий на незаконный участок памяти, сам незаконен. Но если обнаружилась такая незаконная ситуация, то должна быть немедленная реакция на ошибку, это описано в статье «Обработка ошибок».

пока процессоры не будут поддерживать генерацию исключения при переполнении аппаратно и без оверхеда, не стоит ожидать такого контроля в языках программирования, ориентированных на высокую производительность.
Биты переполнения и так устанавливаются, хотим мы этого или нет. А вот условный переход по условию «переполнение» замедляет код не в разы, а лишь на проценты — даже не на десятки процентов. Дмитрий Караваев на этом сайте озвучивал цифры на сей счёт.

Не [совсем] понял, о чём это
обращения по абсолютным адресам
int* ptr = (int*) 1234; // запросто получаем абсолютный адрес
*ptr = anything;        // и пишем по нему
Неужели это нормально?

синтаксис, прогибающий под себя программистов, а-ля Forth
В Форте все слова должны быть разделены пробелами, нельзя написать, например
A+B-C
Обязательно
A + B — C
А постфиксная запись? На неё мозги надо особо настраивать.

зрительных ориентиров в тексте, позволяющих отличить операции от операндов
Форт вполне допускает такую запись:
  blabla_1 blabla_2 blabla_3 
Но что тут функция, а что операнд? Сколько было операндов в стеке до слова «blabla_2» и сколько после? Вы этого не узнаете, пока не загляните в описание каждого слова.

2021-01-19 alextretyak

Я протестую не против синтаксиса — префиксного или постфиксного, а против семантики постфиксных «++» и «--».
Здорово, что протестуете :)(:, но что конкретно вы предлагаете?
  1. чтобы
    i++
    работало точно также как и
    ++i
    ;
  2. запретить
    i++
    , оставив только
    ++i
    ;
  3. [или что мне больше всего нравится:] разрешить
    ++i
    ,
    i++
    и
    arr[++i]
    , но запретить
    arr[i++]
    .

Например, присвоение переменной типа int значения типа long, float или double часто делается с искажением значения. В таких случаях надо или явно «убивать» лишние биты значения, либо должно быть реагирование на ошибку
С этим согласен.

Автор против nullable-типов?
Да. Адрес, указывающий на незаконный участок памяти, сам незаконен.
И какие альтернативы?
Давайте разбирать конкретные примеры.
Вот в 11l есть тип
Словарь
\
Dict
(в C++ это
std::map
). У этого типа есть метод
find()
, который по заданному ключу возвращает либо N/null\Н/нуль (если такого ключа в словаре нет), либо соответствующее значение. (Т.е. метод
find()
возвращает nullable-тип
ValueType?
.) Но чтобы использовать значение, которое вернул
find()
, необходимо либо явно проверить его на N/null\Н/нуль, либо использовать оператор
?
(например так:
dict.find(key) ? default_value
). Как вы предлагаете изменить это? Какая сигнатура и тип возвращаемого значения должны быть у метода
find()
?

Вот примеры кода на 11l, где используется метод
find()
:
  1. 111771+.11l (это решение задачи «Множества»)
  2. https://rosettacode.org/wiki/Knapsack_problem/Bounded#11l
  3. https://rosettacode.org/wiki/Longest_common_substring#11l
  4. V dot_pos = token.value(source). {.find(‘.’) ? .len}
    (данная строка соответствует 3-м строкам Python-кода отсюда)

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

Биты переполнения и так устанавливаются, хотим мы этого или нет.
Да, но проверка этих битов далеко не бесплатна. На современных суперскалярных процессорах условные переходы стоят дорого и, вероятно, со временем будут становиться ещё дороже.

Здесь есть ссылка на обсуждение, в котором говорится о двукратном падении производительности:
... a 2x slowdown, which isn't acceptable if Rust wants to compete with C++.

А вот условный переход по условию «переполнение» замедляет код не в разы, а лишь на проценты — даже не на десятки процентов. Дмитрий Караваев на этом сайте озвучивал цифры на сей счёт.
Откуда эти результаты? Можно исходный код тестирующей программы, с помощью которой получены эти данные? [Хотя, и самих конкретных данных в цифрах я не вижу в самой статье (хотя они есть в комментариях и противоречат высказыванию «замедляет код лишь на проценты»).]

В случае озвученного мной выше замедления в 2 раза, источник приводит такой код:
Без проверки на переполнение:
...
add     %esi, %edi
...
С проверкой на переполнение:
...
add     %esi, %edi
jo      <handle_overflow>
...

Из того же источника:
Для компрессии bzip2 падение производительности [при включении контроля целочисленного переполнения] составляет 28%.
А код:
for (int i = 0; i < n; ++i) {
  sum += a[i];
}
выполняется в 6 раз медленнее при включении контроля переполнения!


...Прошло 3 года...


2024-04-13 alextretyak

Автор сайта
В PDP машинные операции инкремента и декремента соответствовали исключительно префиксным операциям Си. Никаких постфиксных операций inc/dec в ассемблере PDP быть просто не могло.
Ильдар
Ахинея. Постфиксные инкремент или декремент вычисляются после ...
Ого, сколько тут знатоков ассемблера PDP собралось.
Ну ладно, не обижайтесь, но по существу, void оказался прав: в PDP-11 действительно есть постинкремент [постфиксным его называть не очень корректно, т.к. слово «постфиксный» обозначает способ записи].
И хотя нужен он, в основном, для загрузки констант в регистры и для реализации выталкивания из стека (для понимания: в x86 инструкция pop eax означает по сути eax = *esp++), но архитектура набора команд PDP-11 позволяет применять постинкремент к любому регистру.
По ссылке на wikibooks.org, которую дал void, полезного мало, да и в целом, его сообщения, очевидно, не располагают к вдумчивому изучению, поэтому приведу простой и наглядный пример ассемблерного кода для PDP-11:
clr r0           ; r0 = 0
mov #000100 r1   ; r1 = 000100
movb (r0)+ (r1)+ ; *r1++ = *r0++
movb (r0)+ (r1)+ ; *r1++ = *r0++
movb (r0)+ (r1)+ ; *r1++ = *r0++
movb (r0)+ (r1)+ ; *r1++ = *r0++
(Обратите внимание, что приёмник указывается после источника, в отличие от Intel-синтаксиса.)
Проверить код можно в симуляторе. Я нашёл симулятор PDP-11 на JavaScript, который работает в браузере и его не нужно скачивать/устанавливать. Вот ссылка.
После копирования асм-кода в текстовое поле ‘Code’ нужно нажать кнопку ‘Compile’, а затем ‘Run’.
В результате выполнения кода 4 байта по адресам 000100, 000101, 000102 и 000103 (в восьмеричной системе счисления) получат значение первых 4-х байт памяти — это машинный код первых двух инструкций.
Вообще, все инструкции в PDP-11 занимают 2 байта (в том числе и movb (r0)+ (r1)+), но для псевдо-инструкции mov #000100 r1 используется занятный трюк. Т.к. это по сути инструкция mov (pc)+, r1, после которой идёт двухбайтовая константа 000100:
mov (pc)+, r1
.word 000100
(Если нажать кнопочку ‘View Binary’, можно заметить, что машинный код для этой пары строк совпадает с тем, что генерируется для одной строки mov #000100 r1.)

Для понимания работы этого трюка дам небольшую теоретическую справку (которая, правда, относится к процессорам CISC-архитектуры с инструкциями переменной длины, но в данном случае эта логика подходит и для PDP-11).
pc (program counter, он же r7) — это аналог регистра eip/rip из x86. В нём хранится адрес [номер байта в памяти] текущей выполняемой инструкции. Первая фаза обработки инструкции процессором (если не учитывать fetch) — декодирование. В процессе декодирования инструкции процессор увеличивает значение в регистре pc/eip/rip на величину, равную размеру этой инструкции в байтах (в случае PDP-11 это всегда 2 байта) [таким образом, к началу следующей фазы — выполнения инструкции — регистр pc/eip/rip будет указывать уже на следующую инструкцию]. После того, как текущая инструкция будет выполнена, процессор начинает обработку следующей инструкции, т.е. той инструкции, на которую ссылается/указывает новое значение регистра pc/eip/rip. Если в процессе исполнения инструкции значение регистра pc/eip/rip было изменено (например, в x86 инструкция jmp <метка> по логике это просто mov eip, <адрес_метки>), то следующей исполняемой процессором инструкцией будет та, которой соответствует уже новое значение pc/eip/rip.

Так вот, после декодирования инструкции mov (pc)+, r1, регистр pc увеличивается на 2 и указывает на .word 000100, т.е. на слово памяти в котором вместо команды хранится число 000100. В процессе исполнения инструкции mov (pc)+, r1 это число будет прочитано из памяти и записано в регистр r1. В завершение исполнения инструкции сработает постинкремент и значение регистра pc ещё раз увеличится на 2 (не на 1, т.к. это инструкция mov, а не movb), т.е. процессор как бы «перепрыгнет» слово в памяти, в котором располагается число 000100, и начнёт обработку следующей за ним инструкции.

Этот трюк возможен только благодаря поддержке постинкремента, который в данном случае применяется к регистру pc.
[В архитектуре ARM64, к примеру, все инструкции имеют размер 4 байта, и чтобы загрузить в регистр 32-х или 64-х разрядную константу приходится выкручиваться с literal pool, либо генерировать цепочку из 16-разрядных mov-ов [{}]. Ну а в x86-64 проблема загрузки в регистр 32-х или 64-х разрядных констант решается переменной длиной инструкций.]

Примечательно, что постдекремента и преинкремента в PDP-11 нет: есть только постинкремент и предекремент, который обозначается как −(r0).

2024-04-13 Автор сайта

Всё, что Вы так подробно описываете, справедливо с точки зрения микропрограммного управления. К примеру, push сперва заталкивает что-то в стек, а потом изменяет адрес вершины стека. Но программист, даже системный, не имеет доступа к микропрограммам. Самый низкий уровень, к которому он имеет доступ, — это машинные операции, которые с точки зрения программирования в машинных кодах монолитны. Невозможно между первым этапом push (запись чего-то в стек) и вторым (изменение адреса вершины стека) втиснуть что-то ещё. Точно так же монолитны инкременты и декременты. Невозможно на уровне машинного кода между этапами выполнения этих операций втиснуть ещё какие-то действия, которые вам хочется.

Поэтому говорить о постдекрементах и преинкрементах совершенно бессмысленно. По форме они префиксные (как на языке ассемблера в синтаксисе Intel, так и на уровне битов операций: коды операций идут впереди операндов), а по семантике они монолитны (что в принципе совпадает с семантикой префиксных «++» и «--» в Си).

А вот постфиксные «++» и «--» в Си вычурны по семантике: их операнды могут принадлежать сразу двум операциям: например, операции присваивания и этим постфиксным операциям, которые выполняются в конце, в последнюю очередь.
while (*dst++ = *src++);
В этом примере получается, что сперва идёт выборка операндов, потом втискивается присвоение, потом проверка на ненулевое значение, потом выход в случае нулевого значения. И только в самом конце — постфиксные инкременты. Префиксные операции не позволяют такого разбиения на этапы.

2024-04-15 alextretyak

Позвольте немного побыть педантом.
Всё, что Вы так подробно описываете, справедливо с точки зрения микропрограммного управления.
Судя по тому, что написано в Википедии, в первых реализациях архитектуры PDP-11 (PDP–11/20 и PDP–11/15) ещё не применялось микропрограммное управление.
Как реализована инструкция push в процессорах x86 я точно не знаю [сколько и какие именно микрооперации ей соответствуют], но суть в том, что в PDP-11 нет специальной инструкции push! И логику push необходимо задавать явно в коде инструкции, что позволяет использовать любой другой регистр в качестве адреса вершины стека, а также выбрать нестандартное направление роста стека. И при всём при этом эта логика умещается в одной машинной инструкции [речь, очевидно, идёт об инструкции mov].

К примеру, push сперва заталкивает что-то в стек, а потом изменяет адрес вершины стека.
push eax работает как *--esp = eax, т.е. сначала уменьшает адрес вершины стека, а затем помещает данные по этому адресу. Т.е. образно говоря, в push используется предекремент, а в pop — постинкремент. (И это не только в x86, а такое соглашение принято также в ARM и большинстве других архитектур.)

Самый низкий уровень, к которому он имеет доступ, — это машинные операции
Да, именно об этом уровне и идёт речь. PDP-11 поддерживает постинкремент и предекремент на уровне машинных операций.
Машинные инструкции PDP-11 с двумя операндами имеют следующий формат:
BOOOsssSSSdddDDD
│└┬┘└┬┘└┬┘└┬┘└┬┘
│ │  │  │  │  └─ 0-2 биты: регистр-приёмник
│ │  │  │  └──── 3-5 биты: режим адресации регистра-приёмника
│ │  │  └─────── 6-8 биты: регистр-источник
│ │  └───────── 9-11 биты: режим адресации регистра-источника
│ └─────────── 12-14 биты: код операции
└───────────────── 15 бит: признак byte-инструкций
Например, инструкция mov r3 r5 кодируется как 010305. 1 — код операции, 03 — источник, 05 — приёмник.
Среди всех режимов адресации нам интересны следующие четыре:
┌───┬───┬────────────────┐
│Bin│Oct│Assembler Syntax│
├───┼───┼────────────────┤
│000│ 0 │ Rn             │
│001│ 1 │(Rn)            │
│010│ 2 │(Rn)+           │
│100│ 4 │-(Rn)           │
└───┴───┴────────────────┘
Несмотря на термин «режим адресации», режимы 2 и 4 фактически имеют семантику постфиксного инкремента и префиксного декремента из языка Си.
Вот примеры инструкций (в т.ч. с использованием постинкремента и предекремента):
; Во второй колонке соответствующий код на языке Си
; В третьей колонке машинный код в восьмеричной сис. сч.
movb r3    r5   ;  r5 =  r3      ; 11│0│3│0│5
mov  r3    r5   ;  r5 =  r3      ; 01│0│3│0│5
mov (r3)   r5   ;  r5 = *r3      ; 01│1│3│0│5
mov  r3   (r5)  ; *r5 =  r3      ; 01│0│3│1│5
mov (r3)  (r5)  ; *r5 = *r3      ; 01│1│3│1│5

mov (r3)+ (r5)  ; *r5   = *r3++  ; 01│2│3│1│5
mov (r3)  (r5)+ ; *r5++ = *r3    ; 01│1│3│2│5
mov (r3)+ (r5)+ ; *r5++ = *r3++  ; 01│2│3│2│5

mov (r3) -(r5)  ; *--r5 = *r3    ; 01│1│3│4│5
...

add  r3    r5   ;  r5   +=  r3   ; 06│0│3│0│5
...
add (r3)  (r5)  ; *r5   += *r3   ; 06│1│3│1│5
add (r3)+ (r5)  ; *r5   += *r3++ ; 06│2│3│1│5
add (r3)  (r5)+ ; *r5++ += *r3   ; 06│1│3│2│5
add (r3)+ (r5)+ ; *r5++ += *r3++ ; 06│2│3│2│5
add -(r3) (r5)  ; *r5   += *--r3 ; 06│4│3│1│5
...

sub  r3    r5   ;  r5   -=  r3   ; 16│0│3│0│5
...

Поэтому говорить о постдекрементах и преинкрементах совершенно бессмысленно.
Это вопрос терминологии. Называть режимы адресации (Rn)+ и -(Rn) постинкрементом и предекрементом придумал не я. Это хотя и не официальное их наименование (официально в документации DEC они называются автоинкремент и автодекремент), но вполне распространённое:
https://news.ycombinator.com/item?id=24817321:

... pre/post-increment/decrement addressing is baked into the instruction set...
...
... 4 is the pre-decrement mode ...
https://stackoverflow.com/questions/17436141/...:

PDP-11 had post-increment and pre-decrement.

while (*dst++ = *src++);
... потом выход в случае нулевого значения. И только в самом конце — постфиксные инкременты.
И всё-таки инкременты выполняются перед выходом. А если посмотреть на ассемблерный код, который генерирует компилятор gcc для данного примера (https://godbolt.org/z/zT4EoTh6W), то видно, что инкременты выполняются даже перед присваиванием (причём выполняются полностью — компилятор не только инкрементирует значение в регистрах, соответствующих dst и src, но и сохраняет их в отведённой для них памяти для локальных переменных перед присвоением *dst). [Почему компилятору разрешено так поступать — это уже другой вопрос, который разбирать в деталях здесь будет уже неуместно, как я считаю.]
Вообще, если переписать этот пример с использованием if и goto
begin_while:
bool while_condition = (*dst++ = *src++) != 0;
if (!while_condition) goto end_while;
// Здесь располагается тело цикла while, но в данном случае оно пустое
goto begin_while;
end_while:
то окажется, что система команд PDP-11 может в точности повторить этот Сишный код:
mov #100 r0 ; r0 - src
mov #200 r1 ; r1 - dst

begin_while:
movb (r0)+ (r1)+ ; bool while_condition = (*dst++ = *src++) != 0;
beq end_while    ; if (!while_condition) goto end_while;
br begin_while   ; goto begin_while;
end_while:
halt

.= 100
; Копируемые данные:
.word 123456
.word 007700
.word 000000

.= 200
; Область памяти назначения забиваем мусором
; (для проверки того, что данные из источника
; скопировались полностью, включая завершающий
; нулевой байт)
.word 111111
.word 111111
.word 111111
.word 111111
После выполнения этой программы данные по адресу 100 (включая завершающий нулевой байт) скопируются в память по адресу 200.

2024-04-18 void

Почему компилятору разрешено так поступать — это уже другой вопрос, который разбирать в деталях здесь будет уже неуместно, как я считаю
Уместно-уместно. Причина одна, и это тоже один из признаков устаревшего языка: Undefined Behaviour в стандарте, причём UB is not error, а намёки компилятору:
Вот здесь есть дырка в понимании поведения абстрактного исполнителя интерпретатором/модели памяти и верификатора стандарта компилятором.
В нормально, корректно определённом языке на мой взгляд — не должно быть UB в принципе. Как, например, определено в Ada. В С же эта дыра в понимании вполне приемлема — в контексте того, что поведение по умолчанию в случае UB будет такое, как в нижележащем варианте абстрактного исполнителя:
  1. интерпретируемого BCPL, откуда в Си пришла семантика указателей (дырявая),
  2. вот этот пример с ассемблером CISC процессоров PDP-11 либо MC68k.
Налицо протечка абстракций из более нижележащего уровня или просто исторически раннего. Chris Lattner, автор компилятора LLVM где-то писал в рассылке, что на самом деле это мешает. Действительно низкоуровневый язык мог бы более эффективно загрузить вычислительные узлы.

Но у компилятора Си нет такой информации. Из-за UB он должен перебирать варианты, пытаться оптимизировать, самостоятельно догадываться, как именно это UB следует понимать. Что на мой взгляд довольно глупо — более корректно было бы более правильно определить операционную семантику абстрактного исполнителя, затыкая эту дыру в понимании стандарта, чтобы никакого UB в принципе в правильно определённом языке не возникало, по определению.

2024-04-19 Автор сайта

alextretyak
И всё-таки инкременты выполняются перед выходом.
Да, здесь Вы правы, когда выход сделан, то поздно пить Боржоми делать инкремент. Но как инкремент может делаться перед присвоением? ...
...
Выполню этот цикл:
    while (*dst++ = *src++);
...
То есть произошло следующее:

То есть постинкременты делаются после присваивания! Да и как они сделались бы после инкремента? Ведь тогда бы произошло присвоение 8-го байта! А этого не было.

... постфиксный инкремент в Си позволяет втискивать перед финальным увеличением много чего. В отличие от машинной команды, которой нельзя передать другую машинную команду, которую надо выполнить до завершения первой. Поэтому правильно Вы говорите, что для машинных кодов постинкременты — это вопрос терминологии. Видимо разработчики, реализуя в железе машинные команды, вынесли терминологию со своей кухни на всеобщее обозрение.

void
это тоже один из признаков устаревшего языка: Undefined Behaviour в стандарте, причём UB is not error
Так и есть!!! Спасибо, что Вы взяли меня за руку и подвели к слону, которого я и не заметил. И сам нарывался на неопределённое поведение и возмущался этим. И коллеги. И сколько пишут про это. Это должно идти первым пунктом в этой статье! А я не замечал слона в центре комнаты. И Ada, конечно, хороший пример.

2024-04-23 alextretyak

void
Уместно-уместно. Причина одна, и это тоже один из признаков устаревшего языка: Undefined Behaviour
Undefined behaviour — штука, конечно, скверная. Вот только в коде while (*dst++ = *src++); нет никакого undefined behaviour. У выражения *dst++ = *src++ вполне себе определённое поведение и его производимый эффект не зависит от порядка выполнения инкрементов и присваивания, т.к. инкрементируются и присваиваются различные сущности: инкрементируется dst, а присваивается *dst. Вот если написать dst++ = src++, тогда здесь будет UB, т.к. dst меняется более одного раза (в ++ и в =) между соседними точками следования. (*dst)++ = (*src)++ — это также UB. И ++dst = 0. И dst++ = 0. Но не *dst++ = 0!
Подробнее можно почитать здесь (там же есть комментарий, почему *p++ = 4 не является UB).

Автор сайта
То есть произошло следующее:

• были скопированы байты 0 — 7, включая седьмой с содержимым 0,
• содержимое 0 седьмого байта послужило сигналом в выходу; этот факт процессор запомнил,
• был выполнен постинкремент переменных src и dst и они стали отличиться от исходных значений не на 7, а на 8!
• и, наконец, выход.
Почему-то в первом пункте вы говорите о результате работы всего цикла, а последние три относятся только к последней итерации цикла.

Давайте я разберу ассемблерный код, который сгенерировал компилятор gcc (ссылку я уже приводил: https://godbolt.org/z/zT4EoTh6W).
.L2:
        mov     rdx, QWORD PTR [rbp-8]  ; rdx = src   ; prev_src = src
        lea     rax, [rdx+1]            ; rax = rdx+1
        mov     QWORD PTR [rbp-8], rax  ; src = rax   ; ++src
        mov     rax, QWORD PTR [rbp-16] ; rax = dst   ; prev_dst = dst
        lea     rcx, [rax+1]            ; rcx = rax+1
        mov     QWORD PTR [rbp-16], rcx ; dst = rcx   ; ++dst
        movzx   edx, BYTE PTR [rdx]     ; dl = *rdx
        mov     BYTE PTR [rax], dl      ; *rax = dl   ; *prev_dst = *prev_src
        movzx   eax, BYTE PTR [rax]     ; al = *rax
        test    al, al                  ; if (al != 0)
        jne     .L2                     ;     goto .L2
(Оптимизации были выключены, чтобы компилятор генерировал максимально понятный код.)
Как можете сами убедиться, оба инкремента (src++ и dst++) выполняются перед присвоением. Как же тогда запись осуществляется по старому значению dst, если dst инкрементируется перед присваиванием? Всё просто: компилятор сохраняет копию неинкрементированного значения src и dst в регистрах.

Вот цитата из стандарта C++ (раздел 7.6.1 Postfix expressions, 7.6.1.5 Increment and decrement):
The value computation of the ++ expression is sequenced before the modification of the operand object.
Google-перевод:
Вычисление значения выражения ++ выполняется до модификации объекта операнда.

Помните я приводил код функции postfix_increment() в этом сообщении?
Компилятор делает буквально следующее:
while (*postfix_increment(dst) = *postfix_increment(src));
Т.е. сначала полностью выполняется правая сторона присваивания — функция postfix_increment(src), при этом значение src инкрементируется, а предыдущее (неинкрементированное) значение, которое вернула функция (назову его prev_src), сохраняется в регистре. Затем полностью выполняется левая сторона присваивания — postfix_increment(dst). И только потом присваивание *prev_dst = *prev_src, и значение *prev_dst сравнивается с нулём.