Почему целочисленное деление на -1 (отрицательное) приводит к FPE?

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

Фрагмент кода 1 выводит -2147483648

int a = 0x80000000;
int b = a / -1;
printf("%d\n", b);

Code snippet 2 outputs nothing, and gives a Floating point exception

int a = 0x80000000;
int b = -1;
int c = a / b;
printf("%d\n", c);

Я хорошо знаю причину результата фрагмента кода 1 (1 + ~INT_MIN == INT_MIN), но я не совсем понимаю, как целочисленное деление на -1 может генерировать FPE, и я не могу воспроизвести его на своем телефоне Android (AArch64, GCC 7.2.0) . Код 2 просто выводит то же самое, что и Код 1, без каких-либо исключений. Это скрытая ошибка процессора x86?

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


Изменить: я связался со своим другом, и он протестировал код на Ubuntu 16.04 (Intel Kaby Lake, GCC 6.3.0). Результат соответствовал заявленному назначению (код 1 выводил указанное, а код 2 завершился с ошибкой при использовании FPE).


person iBug    schedule 23.09.2017    source источник
comment
У меня есть задание объяснить некоторые UB из C - ошибка. Единственный способ объяснить это - сказать, что абсолютно все может случиться.   -  person paxdiablo    schedule 23.09.2017
comment
@paxdiablo Отредактировал вопрос. Это не UB, а поведение, зависящее от архитектуры процессора.   -  person iBug    schedule 23.09.2017
comment
@iBug: в вопросе следует упомянуть архитектуру (тогда речь идет не о C, а о машинном коде, полученном каким-то конкретным компилятором C на какой-то конкретной машине). На x86-64 переполнение целых чисел в Linux может дать SIGFPE   -  person Basile Starynkevitch    schedule 23.09.2017
comment
Я, конечно, не удивлюсь, если ваш первый фрагмент будет скомпилирован с отрицанием, а не с делением. Это простая оптимизация с помощью глазка.   -  person T.C.    schedule 23.09.2017
comment
@ T.C. Ваш комментарий - правильный ответ.   -  person iBug    schedule 23.09.2017
comment
На самом деле вы не знаете результата ни в случае 1, ни в случае 2, поскольку оба приводят вас к НЕОПРЕДЕЛЕННОМУ ПОВЕДЕНИЮ, что означает, что вы можете знать, что делает ваша реализация .... но вы будете никогда не знаешь, что даст любая реализация, соответствующая стандарту.   -  person Luis Colorado    schedule 25.09.2017
comment
@ibug, это не зависит от архитектуры. При переполнении целого числа со знаком (что происходит в обоих случаях) результатом вашего выражения будет НЕОПРЕДЕЛЕННОЕ ПОВЕДЕНИЕ, а не поведение, определяемое реализацией. Прочтите, пожалуйста, стандарт.   -  person Luis Colorado    schedule 25.09.2017
comment
Неопределенное поведение - это ошибка не в другом месте, а в вашем коде. Если вы соглашаетесь с U.B. вы на свой страх и риск. Не беспокойтесь о какой-либо ошибке в другом месте. Никого не заставляют внедрять какие-то U.B. каким-либо связным или определенным образом. Вот что У.Б. средства.   -  person Luis Colorado    schedule 25.09.2017


Ответы (5)


Здесь происходит четыре вещи:

  • gcc -O0 поведение объясняет разницу между вашими двумя версиями: idiv vs. neg. (Пока clang -O0 компилирует их оба с idiv). И почему вы получаете это даже с операндами, постоянными во времени компиляции.

  • x86 idiv поведение при сбое в сравнении с поведением инструкции деления на ARM

  • Если целочисленная математика приводит к доставке сигнала, POSIX требует, чтобы он был SIGFPE: На каких платформах целочисленное деление на ноль вызывает исключение с плавающей запятой? Но POSIX не требует перехвата для каких-либо конкретных целочисленная операция. (Вот почему для x86 и ARM разрешено отличаться).

    Спецификация единой Unix определяет SIGFPE как ошибочную арифметическую операцию. Он назван в честь с плавающей запятой, что сбивает с толку, но в обычной системе с FPU в состоянии по умолчанию только целочисленные математические вычисления будут повышать его. На x86 только целочисленное деление. На MIPS компилятор может использовать add вместо addu для математических вычислений со знаком, чтобы вы могли получить ловушки при подписанном добавлении переполнения. ( gcc использует addu даже для подписанных, но детектор неопределенного поведения может использовать add .)

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


gcc без параметров совпадает с gcc -O0.

-O0 Сократите время компиляции и заставьте отладку давать ожидаемые результаты. Это значение по умолчанию.

Это объясняет разницу между вашими двумя версиями:

Мало того, что gcc -O0 не пытается оптимизировать, он активно деоптимизирует, чтобы сделать asm, который независимо реализует каждый оператор C в функции. Это позволяет jump команде gdb использовать работать безопасно, позволяя вам перейти на другую строку внутри функции и действовать так, как будто вы действительно прыгаете в исходном коде C. Почему clang создает неэффективный asm с -O0 (для этой простой суммы с плавающей запятой)? объясняет, как и почему -O0 компилируется именно так.

Он также не может ничего предполагать о значениях переменных между операторами, потому что вы можете изменять переменные с помощью set b = 4. Очевидно, что это катастрофически плохо сказывается на производительности, поэтому код -O0 работает в несколько раз медленнее, чем обычный код, и почему оптимизация для -O0, в частности, полная чушь. Это также делает вывод -O0 asm действительно шумным и трудным для человека. прочтите, из-за того, что все хранятся / перезагружаются, и отсутствуют даже самые очевидные оптимизации.

int a = 0x80000000;
int b = -1;
  // debugger can stop here on a breakpoint and modify b.
int c = a / b;        // a and b have to be treated as runtime variables, not constants.
printf("%d\n", c);

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

Чтобы оценить a/b, gcc -O0 должен выдать код для перезагрузки a и b из памяти и не делать никаких предположений об их значении.

Но с int c = a / -1; вы не можете изменить -1 с помощью отладчика, поэтому gcc может и реализует этот оператор так же, как он реализовал бы int c = -a;, с инструкцией x86 neg eax или AArch64 neg w0, w0, окруженной загрузка (а) / магазин (в). В ARM32 это rsb r3, r3, #0 (обратное вычитание: r3 = 0 - r3).

Однако clang5.0 -O0 не выполняет эту оптимизацию. Он по-прежнему использует idiv для a / -1, поэтому обе версии будут давать сбой на x86 с лязгом. Почему gcc вообще оптимизируется? См. Отключение всех параметров оптимизации в GCC. gcc всегда преобразуется через внутреннее представление, а -O0 - это минимальный объем работы, необходимый для создания двоичного файла. В нем нет тупого и буквального режима, который пытается сделать asm максимально похожим на исходный.


x86 idiv против AArch64 sdiv:

x86-64:

    # int c = a / b  from x86_fault()
    mov     eax, DWORD PTR [rbp-4]
    cdq                                 # dividend sign-extended into edx:eax
    idiv    DWORD PTR [rbp-8]           # divisor from memory
    mov     DWORD PTR [rbp-12], eax     # store quotient

В отличие от imul r32,r32, нет двух операндов idiv, которые не имеют ввода верхней половины делимого. Во всяком случае, это не имеет значения; gcc использует его только с edx = копиями знакового бита в eax, поэтому на самом деле он выполняет 32b / 32b = ›32b частное + остаток. Как указано в руководстве Intel, idiv вызывает #DE на:

  • делитель = 0
  • Результат со знаком (частное) слишком велик для места назначения.

Переполнение может легко произойти, если вы используете полный набор делителей, например для int result = long long / int с одним делением 64b / 32b = ›32b. Но gcc не может сделать эту оптимизацию, потому что ему не разрешено создавать код, который будет давать сбой, вместо того, чтобы следовать правилам целочисленного продвижения C и выполнять 64-битное деление и затем усечение до int. Он также не оптимизируется даже в тех случаях, когда известно, что делитель достаточно велик, чтобы не удалось #DE

При делении 32b / 32b (с cdq) единственный вход, который может переполняться, - это INT_MIN / -1. Правильное частное - это 33-битное целое число со знаком, то есть положительное 0x80000000 с начальным нулевым битом знака, чтобы сделать его положительным целым числом со знаком с дополнением до 2. Поскольку это не соответствует eax, idiv вызывает исключение #DE. Затем ядро ​​доставляет SIGFPE.

AArch64:

    # int c = a / b  from x86_fault()  (which doesn't fault on AArch64)
    ldr     w1, [sp, 12]
    ldr     w0, [sp, 8]          # 32-bit loads into 32-bit registers
    sdiv    w0, w1, w0           # 32 / 32 => 32 bit signed division
    str     w0, [sp, 4]

Команды аппаратного деления ARM не вызывают исключения для деления на ноль или для INT_MIN/-1 переполнения. Нейт Элдридж прокомментировал:

В полном справочном руководстве по архитектуре ARM указано, что UDIV или SDIV при делении на ноль просто возвращают ноль в качестве результата без каких-либо указаний на то, что произошло деление на ноль (C3.4.8 в версии Armv8-A). Без исключений и без флагов - если вы хотите поймать деление на ноль, вам нужно написать явный тест. Аналогично, знаковое деление INT_MIN на -1 возвращает INT_MIN без указания переполнения.

AArch64 sdiv документация не упоминает никаких исключений.

Однако программные реализации целочисленного деления могут вызывать: http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.faqs/ka4061.html. (gcc по умолчанию использует библиотечный вызов для деления на ARM32, если вы не установили -mcpu, у которого есть HW-деление.)


C Неопределенное поведение.

Как объясняет PSkocik, INT_MIN / -1 - неопределенное поведение в C, как и любое целочисленное переполнение со знаком. Это позволяет компиляторам использовать инструкции аппаратного разделения на машинах, таких как x86, без проверки этого особого случая. Если бы это было не ошибкой, неизвестные входные данные потребовали бы сравнения во время выполнения и Branch проверяет, и никто не хочет, чтобы C требовал этого.


Подробнее о последствиях УБ:

При включенной оптимизации компилятор может предположить, что a и b по-прежнему имеют свои заданные значения при выполнении a/b. Затем он может видеть, что программа имеет неопределенное поведение, и, таким образом, может делать все, что захочет. gcc выбирает создание INT_MIN, как это было бы с -INT_MIN.

В системе с дополнением до 2 самое отрицательное число само по себе отрицательное. Это неприятный случай для дополнения 2, потому что это означает, что abs(x) все еще может быть отрицательным. https://en.wikipedia.org/wiki/Two%27s_complement#Most_negative_number

int x86_fault() {
    int a = 0x80000000;
    int b = -1;
    int c = a / b;
    return c;
}

скомпилируйте это с gcc6.3 -O3 для x86-64

x86_fault:
    mov     eax, -2147483648
    ret

но clang5.0 -O3 компилируется в (без предупреждения даже с -Wall -Wextra`):

x86_fault:
    ret

Неопределенное поведение действительно полностью неопределенное. Компиляторы могут делать все, что хотят, в том числе возвращать весь мусор, который был eax при входе в функцию, или загружать нулевой указатель и недопустимую инструкцию. например с gcc6.3 -O3 для x86-64:

int *local_address(int a) {
    return &a;
}

local_address:
    xor     eax, eax     # return 0
    ret

void foo() {
    int *p = local_address(4);
    *p = 2;
}

 foo:
   mov     DWORD PTR ds:0, 0     # store immediate 0 into absolute address 0
   ud2                           # illegal instruction

Ваш случай с -O0 не позволял компиляторам видеть UB во время компиляции, поэтому вы получили ожидаемый вывод asm.

См. Также Что каждый программист на C должен знать о неопределенном Поведение (тот же пост в блоге LLVM, на который ссылается Базиль).

person Peter Cordes    schedule 23.09.2017
comment
Почему это было отклонено? Я сделал это слишком длинным и бессвязным? Я не хотел предполагать очень много начальных знаний, но это превратилось в длинный ответ: / - person Peter Cordes; 23.09.2017
comment
Я проверил вывод сборки и сразу понял, что происходит, когда увидел, что a/-1; был скомпилирован в negl %eax, а a/b был скомпилирован в cltd; idivl %ebx. Спасибо за долгое объяснение и терпение! - person iBug; 24.09.2017
comment
В полном справочном руководстве по архитектуре ARM указано, что UDIV или SDIV при делении на ноль просто возвращают ноль в качестве результата без каких-либо указаний на то, что произошло деление на ноль (C3.4.8 в версии Armv8-A). Без исключений и без флагов - если вы хотите поймать деление на ноль, вам нужно написать явный тест. Аналогично, знаковое деление INT_MIN на -1 возвращает INT_MIN без указания переполнения. - person Nate Eldredge; 21.09.2020

Знаковое int деление на дополнение до двух не определено, если:

  1. делитель равен нулю, ИЛИ
  2. делимое равно INT_MIN (== _ 3_, если int равно int32_t), а делитель -1 (в дополнении до двух -INT_MIN > INT_MAX, что вызывает целочисленное переполнение, что является неопределенным поведением в C)

(https://www.securecoding.cert.org/confluence/display/c/INT33-C.+Ensure+that+division+and+remainder+operations+do+not+result+in+divide-by-zero+errors рекомендует заключать целочисленные операции в функции, которые проверяют такие крайние случаи)

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

person PSkocik    schedule 23.09.2017

С неопределенным поведением очень плохое может случиться, а иногда и случается.

Ваш вопрос не имеет смысла на C (прочтите Lattner на UB). Но вы можете получить код ассемблера (например, созданный gcc -O -fverbose-asm -S) и позаботиться о поведении машинного кода.

На x86-64 с Linux целочисленное переполнение (а также целочисленное деление на ноль, IIRC) дает сигнал SIGFPE. См. signal (7)

Кстати, в PowerPC целочисленное деление на ноль, по слухам, дает -1 на уровне машины (но некоторые компиляторы C генерируют дополнительный код для проверки этого случая).

Код в вашем вопросе - неопределенное поведение в C. Сгенерированный код ассемблера имеет определенное поведение (зависит от ISA и процессор).

(задание сделано для того, чтобы вы узнали больше о UB, в частности блог Латтнера, который вам следует обязательно прочитать)

person Basile Starynkevitch    schedule 23.09.2017
comment
Только gcc -O0 объясняет наблюдаемое поведение. Все, кроме -O0, будет выполнять постоянное распространение во время компиляции. Но -O0 должен создать код, который по-прежнему реализует исходный код C, если вы изменяете значения переменных во время выполнения с помощью отладчика. например он не может оптимизировать перезагрузку и x86 idiv во 2-й версии, потому что int b = -1; может не быть -1 в следующем операторе. - person Peter Cordes; 23.09.2017
comment
Только целочисленное переполнение от деления повышает SIGFPE на x86-64 Linux. (На самом деле IDK, что происходит, если вы разбрасываете инструкции into после подписания сложение / вычитание / что угодно; #OF исключение может обрабатываться ядром так же, как #DE исключение.) Но компилятор этого не делает, поэтому деление - единственная целочисленная операция, которая когда-либо перехватывалась в C на x86-64. - person Peter Cordes; 23.09.2017
comment
Написал это в ответ. - person Peter Cordes; 23.09.2017

На x86, если вы делите на фактическое использование операции idiv (что на самом деле не обязательно для постоянных аргументов, даже для переменных, которые заведомо являются постоянными, но это все равно произошло), INT_MIN / -1 - один из случаев, когда возникает #DE (ошибка деления). Это действительно частный случай, когда частное выходит за пределы допустимого диапазона, в общем, это возможно, потому что idiv делит сверхширокое делимое на делитель, поэтому многие комбинации вызывают переполнение, но INT_MIN / -1 - единственный случай, который не является div-by -0, к которому вы обычно можете получить доступ из языков более высокого уровня, поскольку они обычно не предоставляют возможности сверхширокого дивиденда.

Linux раздражающе сопоставляет #DE с SIGFPE, что, вероятно, сбило с толку всех, кто имел дело с ним в первый раз.

person harold    schedule 23.09.2017
comment
POSIX требует SIGFPE, если сигнал вообще есть. : / Но в системах Linux / GNU, очевидно, вы можете отличить FPE_INTDIV_TRAP от фактических причин FP: gnu.org/software/libc/manual/html_node/. См. Мой ответ для объяснения того, почему idiv необходимо с gcc -O0 для одного, но не для обоих случаев. - person Peter Cordes; 23.09.2017

Оба случая странные, поскольку первый состоит в делении -2147483648 на -1 и должен давать 2147483648, а не результат, который вы получаете. Деление на -1 (как умножение) должно изменить знак делимого, чтобы стать положительным числом. Но в int такого положительного числа нет (вот что вызывает У.Б.)

0x80000000 не является допустимым int числом в 32-битной архитектуре (как указано в стандарте), которое представляет числа в дополнении до двух. Если вы вычислите его отрицательное значение, вы снова вернетесь к нему, поскольку у него нет противоположного числа около нуля. Когда вы выполняете арифметику с целыми числами со знаком, это хорошо работает для сложения и вычитания целых чисел (всегда с осторожностью, так как вы довольно легко переполните, когда вы добавляете наибольшее значение к некоторому int), но вы не можете безопасно использовать его для умножения или деления. Итак, в этом случае вы вызываете Неопределенное поведение. Вы всегда вызываете неопределенное поведение (или поведение, определенное реализацией, которое похоже, но не одно и то же) при переполнении целыми числами со знаком, поскольку реализации сильно различаются в реализации этого.

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

Конкретно, 0x80000000, представленный в дополнении до двух, является

1000_0000_0000_0000_0000_0000_0000

если дополнить это число, мы получим (сначала дополним все биты, затем прибавим единицу)

0111_1111_1111_1111_1111_1111_1111 + 1 =>
1000_0000_0000_0000_0000_0000_0000  !!!  the same original number.

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

1000_0000_0000_0000_0000_0000_0000 &
0111_1111_1111_1111_1111_1111_1111 =>
0000_0000_0000_0000_0000_0000_0000

это число, которое вы используете в качестве дивиденда.

Но, как я сказал ранее, это то, что может происходить в вашей системе, но не уверен, поскольку в стандарте указано, что это неопределенное поведение, и, таким образом, вы можете получить любое другое поведение со своего компьютера / компилятор.

Различные результаты, которые вы получаете, вероятно, являются результатом первой операции, выполняемой компилятором, а вторая - самой программой. В первом случае вы присваиваете переменной 0x8000_0000, а во втором вы вычисляете значение в программе. Оба случая являются неопределенным поведением, и вы видите, что это происходит прямо у вас на глазах.

# ПРИМЕЧАНИЕ 1

Что касается компилятора, а в стандарте ничего не говорится о допустимых диапазонах int, которые должны быть реализованы (стандарт обычно не включает 0x8000...000 в двух дополнительных архитектурах), правильное поведение 0x800...000 в двух дополнительных архитектурах должно быть таким: поскольку он имеет наибольшее абсолютное значение для целого числа этого типа, чтобы получить результат 0 при делении числа на него. Но аппаратные реализации обычно не позволяют делить на такое число (поскольку многие из них даже не реализуют целочисленное деление со знаком, а моделируют его из беззнакового деления, поэтому многие просто извлекают знаки и выполняют беззнаковое деление), что требует проверка перед делением, и, как в стандарте указано Неопределенное поведение, реализациям разрешено свободно избегать такой проверки и запрещать деление на это число. Они просто выбирают целочисленный диапазон от 0x8000...001 до 0xffff...fff, а затем от 0x000..0000 до 0x7fff...ffff, запрещая значение 0x8000...0000 как недопустимое.

person Luis Colorado    schedule 25.09.2017
comment
это число, которое вы используете в качестве делителя - Нет, 0x80000000 был дивидендом. Конечно, на аппаратном уровне реальный дивиденд - 64-битный: FFFFFFFF:80000000 в EDX: EAX, после знака компилятора - расширяет дивиденд, потому что единственные инструкции деления x86 принимают делимое вдвое больше, чем другие входные и выходные операнды. Таким образом, полное частное равно 00000001:00000000, что не подходит для 32-битного регистра, поэтому исключение деления #DE. Но не делить на ноль. В какой-то момент в вашем ответе вы, по-видимому, перепутали дивиденд и делитель и / или придумали что-то вроде удаления знакового бита. - person Peter Cordes; 21.09.2020
comment
@PeterCordes, на 64-битных машинах размер int составляет 32 бит, по крайней мере, в Linux и FreeBSD. Вам не нужно делать 64-битное деление, чтобы получить это. Как я сказал в своем ответе, стандарт обычно не распознает асимметричные диапазоны около нуля, заявляя, что int идет от -2 ^ 31-1 до 2 ^ 31-1, учитывая использование -2 ^ 31 как неопределенное поведение. Здесь может происходить следующее: в первом случае выражение оценивает компилятор, а во втором - среда выполнения. - person Luis Colorado; 21.09.2020
comment
@PeterCordes, Приношу свои извинения за замену имен делителя и делимого, я всегда ошибаюсь, но вы правы, a - это делимое, делитель -1 все время. - person Luis Colorado; 21.09.2020
comment
int деление в C дает результат int, поэтому компилятор должен использовать 32-битный размер операнда для x86 idiv . Этот размер idiv требует 64-битного делимого в EDX: EAX. Да, как я объяснил в своем ответе, разница заключается в том, что время выполнения idiv против оптимизации времени компиляции до простого neg (которое по-прежнему является тем же целочисленным переполнением со знаком, но не ошибкой). Вы неправильно объяснили настоящую причину неисправности, сказав, что она как-то связана с этим, затем вы удалили знаковый бит и показали, что дивиденд становится равным нулю. Но 0 / - 1 не виноват. - person Peter Cordes; 21.09.2020
comment
TL: DR: все версии имеют подписанный UB с переполнением в C. Любое объяснение зависит от просмотра asm, чтобы точно увидеть, как он скомпилирован с конкретным компилятором, и правил ISA для этих инструкций. Помните, что UB не означает, что требуется неисправность, это означает, что может случиться все, в том числе простая упаковка. OP знает свой UB и хотел получить объяснение симптомов конкретных реализаций C. - person Peter Cordes; 21.09.2020
comment
@PeterCordes, боюсь, что нет. В первом примере обычно компилятор производит вычисление значения (поскольку это инициализатор переменной в постоянном выражении), поэтому код ассемблера уже получает вычисленное значение. Во втором примере вы можете выглядеть как хотите, но как У.Б. был запущен, каждая реализация может генерировать совершенно другой и несвязанный код. - person Luis Colorado; 22.09.2020
comment
В том-то и дело. Этот вопрос касается деталей реализации, которые привели к различному поведению в каждой реализации для этого UB. Но обратите внимание, что при компиляции OP (оптимизация отключена) UB не виден во время компиляции. Если оптимизация отключена, постоянное распространение по операторам не выполняется. Это создает asm, который дает согласованные результаты, даже если вы измените a на какое-то другое значение перед оператором, содержащим деление. Прочтите мой ответ, он все объясняет. При оптимизации обе версии будут эквивалентны. - person Peter Cordes; 22.09.2020
comment
Если вы хотите оставить этот отказ от ответа, в котором подробно рассказывается о вещах, которые не имеют отношения к делу, это ваше дело; после этого последнего комментария я пытался убедить вас удалить его. Идентичность -x = ~x + 1 не имеет ничего общего. К счастью, отрицательные голоса за этот ответ должны помешать будущим читателям думать, что INT_MIN в качестве дивиденда может быть проблемой в общем случае (для других делителей). Это не; ISO C требует, например, чтобы INT_MIN / -4 был четко определен. Гипотетический C на HW только с беззнаковым делением должен его обработать. - person Peter Cordes; 22.09.2020
comment
Полностью согласен с вами ... нет смысла в этом ответе .... вы единственная ссылка и абсолютная правда. Я не спрашивал у вас ни ваших комментариев, ни вашего последнего комментария по поводу вашего намека на стирание ответа ... Я думаю, что вы единственное упоминание, которое можно услышать в этой теме. Полный вам авторитет. Спасибо за полезные советы, и еще раз посмотрите на SO! - person Luis Colorado; 22.09.2020
comment
@PeterCordes Это самый правильный и простой ответ. Спасибо. - person Skynight; 08.03.2021