Почему printf («% f», 0); дать неопределенное поведение?

87

Заявление

printf("%f\n",0.0f);

печатает 0.

Однако заявление

printf("%f\n",0);

печатает случайные значения.

Я понимаю, что проявляю какое-то неопределенное поведение, но не могу понять почему.

Значение с плавающей запятой, в котором все биты равны 0, по-прежнему допустимо floatсо значением 0.
floatИ intимеет тот же размер на моей машине (если это вообще актуально).

Почему использование целочисленного литерала вместо литерала с плавающей запятой printfвызывает такое поведение?

PS такое же поведение можно увидеть, если я использую

int i = 0;
printf("%f\n", i);
Тревор Хики
источник
37
printfожидает double, и вы даете ей int. floatи intможет иметь такой же размер на вашем компьютере, но 0.0fфактически преобразуется в a doubleпри добавлении в список аргументов с переменным числом аргументов (и printfожидает этого). Короче говоря, вы не выполняете свою часть сделки, printfосновываясь на спецификаторах, которые вы используете, и аргументах, которые вы предоставляете.
WhozCraig
22
Varargs-функции не могут автоматически преобразовывать аргументы функции в тип соответствующего параметра, потому что не могут. Необходимая информация недоступна для компилятора, в отличие от не-varargs функций с прототипом.
EOF
3
Ооо ... "вариативность". Я только что выучил новое слово ...
Майк Робинсон
3
Следующее , что нужно попробовать это передать (uint64_t)0вместо 0и посмотреть ли вы все равно получите случайное поведение (предполагая , что doubleи uint64_tимеют одинаковый размер и выравнивание). Скорее всего, результат будет все еще случайным на некоторых платформах (например, x86_64) из-за того, что разные типы передаются в разных регистрах.
Ian Abbott

Ответы:

121

"%f"Формат требует аргумент типа double. Вы даете ему типовой аргумент int. Вот почему поведение не определено.

Стандарт не гарантирует, что все биты-ноль являются допустимым представлением 0.0(хотя это часто бывает) или любого doubleзначения, или что intи doubleимеют одинаковый размер (помните, что это doubleне так float), или даже если они одинаковы size, что они передаются в качестве аргументов функции с переменным числом аргументов таким же образом.

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

N1570 7.21.6.1 пункт 9:

... Если какой-либо аргумент не является правильным типом для соответствующей спецификации преобразования, поведение не определено.

Аргументы типа floatповышаются до double, поэтому printf("%f\n",0.0f)работает. Аргументы целочисленных типов уже, чем intпродвигаются до intили до unsigned int. Эти правила продвижения (указанные в N1570 6.5.2.2 п.6) не помогают в случае printf("%f\n", 0).

Обратите внимание, что если вы передаете константу 0невариадной функции, которая ожидает doubleаргумент, поведение хорошо определено, при условии, что прототип функции виден. Например, sqrt(0)(после #include <math.h>) неявно преобразует аргумент 0из intв double- потому что компилятор может видеть из объявления, sqrtчто он ожидает doubleаргумент. Нет такой информации для printf. Такие функции с переменными числами, как printfособенные, требуют большей осторожности при написании их вызовов.

Кейт Томпсон
источник
13
Здесь есть пара отличных основных моментов. Во-первых, это doubleне floatзначит, что предположение о ширине OP может не выполняться (вероятно, не выполняется). Во-вторых, предположение, что целочисленный ноль и ноль с плавающей запятой имеют одинаковый битовый шаблон, также не выполняется. Хорошая работа
Легкость гонки на орбите
2
@LucasTrzesniewski: Хорошо, но я не понимаю, как мой ответ ставит вопрос. Я заявил, что floatэто повышено double, но не объяснил почему, но это было не главное.
Кейт Томпсон
2
@ robertbristow-johnson: Компиляторам не нужны специальные перехватчики printf, хотя в gcc, например, есть некоторые, поэтому он может диагностировать ошибки ( если строка формата является буквальной). Компилятор видит объявление printffrom <stdio.h>, в котором говорится, что первый параметр - это, const char*а остальные обозначены , .... Нет, %fэто для doublefloatповышено до double), и %lfдля long double. Стандарт C ничего не говорит о стеке. Он определяет поведение printfтолько при правильном вызове.
Кейт Томпсон
2
@ robertbristow-johnson: В давние времена lint часто выполнял некоторые дополнительные проверки, которые теперь выполняет gcc. Переданный floatобъект printfповышается до double; в этом нет ничего волшебного, это просто языковое правило для вызова вариативных функций. printfсам знает через строку формата, что, по утверждениям, передал ему вызывающий абонент ; если это утверждение неверно, поведение не определено.
Кейт Томпсон
2
Малое Исправление: lдлина модификатор «не имеет никакого эффекта на следующем a, A, e, E, f, F, g, или Gспецификатор преобразования», длина модификатор для long doubleконверсии L. (@ robertbristow-johnson также может быть заинтересован)
Дэниел Фишер,
58

Во-первых, как было сказано в нескольких других ответах, но, на мой взгляд, недостаточно четко сказано: он действительно работает, чтобы предоставить целое число в большинстве контекстов, где библиотечная функция принимает аргумент doubleили float. Компилятор автоматически вставит преобразование. Например, sqrt(0)он четко определен и будет вести себя точно так же sqrt((double)0), и то же самое верно для любого другого выражения целого типа, используемого в нем.

printfотличается. Он отличается, потому что требует переменного количества аргументов. Его функциональным прототипом является

extern int printf(const char *fmt, ...);

Поэтому, когда вы пишете

printf(message, 0);

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

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

Другая половина вопроса заключалась в следующем: учитывая, что (int) 0 и (float) 0.0 в большинстве современных систем представлены как 32 бита, все из которых равны нулю, почему это все равно не работает случайно? Стандарт C просто говорит: «Это не обязательно для работы, вы сами по себе», но позвольте мне указать две наиболее распространенные причины, по которым это не сработает; это, вероятно, поможет вам понять, почему это не требуется.

Во-первых, по историческим причинам, когда вы передаете a floatчерез список переменных аргументов, он продвигается к нему double, который в большинстве современных систем имеет ширину 64 бита. Таким образом, printf("%f", 0)вызываемому, ожидающему 64 из них, передается только 32 нулевых бита.

Вторая, не менее важная причина состоит в том, что аргументы функции с плавающей запятой могут передаваться в другом месте, чем целочисленные аргументы. Например, большинство процессоров имеют отдельные файлы регистров для целых чисел и значений с плавающей запятой, поэтому может быть правилом, что аргументы от 0 до 4 идут в регистры с r0 по r4, если они целые числа, но с f0 по f4, если они с плавающей запятой. Итак, printf("%f", 0)ищем этот ноль в регистре f1, но его там совсем нет.

Zwol
источник
1
Существуют ли архитектуры, которые используют регистры для вариативных функций, даже среди тех, которые используют их для обычных функций? Я думал, что это причина того, что вариативные функции должны быть должным образом объявлены, даже если другие функции [кроме функций с аргументами float / short / char] могут быть объявлены с ().
Random832
3
@ Random832 В настоящее время единственное различие между вариативным и обычным порядком вызова функции состоит в том, что в вариативный тип могут быть переданы некоторые дополнительные данные, такие как подсчет истинного числа предоставленных аргументов. В противном случае все будет в том же месте, что и при нормальной работе. См., Например, раздел 3.2 документа x86-64.org/documentation/abi.pdf , где единственная особая обработка переменных - это переданная подсказка AL. (Да, это означает, что реализация va_argнамного сложнее, чем раньше.)
zwol
@ Random832: Я всегда думал, что причина в том, что на некоторых архитектурах функции с известным числом и типом аргументов могут быть реализованы более эффективно с помощью специальных инструкций.
celtschk
@celtschk Возможно, вы думаете об «окнах регистров» на SPARC и IA64, которые должны были ускорить общий случай вызовов функций с небольшим количеством аргументов (увы, на практике они делают прямо противоположное). Они не требуют, чтобы компилятор специально обрабатывал вызовы функций с переменным числом аргументов, потому что количество аргументов в любом месте вызова всегда является константой времени компиляции, независимо от того, является ли вызываемый объект переменным.
zwol
@zwol: Нет, я думал об ret nинструкции 8086, где nбыло жестко запрограммированное целое число, которое, следовательно, не применимо к функциям с переменным числом аргументов. Однако я не знаю, действительно ли какой-либо компилятор C воспользовался этим преимуществом (конечно, компиляторы, отличные от C).
celtschk
13

Обычно, когда вы вызываете функцию, которая ожидает double, но вы предоставляете int, компилятор автоматически преобразует ее в double. Этого не происходит printf, потому что типы аргументов не указаны в прототипе функции - компилятор не знает, что нужно применить преобразование.

Марк Рэнсом
источник
4
Также, printf() в частности , спроектирован так, чтобы его аргументы могли быть любого типа. Вы должны знать, какой тип ожидает каждый элемент в строке формата, и вы должны указать его правильно.
Майк Робинсон,
@MikeRobinson: Ну, любой примитивный тип C. Это очень и очень небольшое подмножество всех возможных типов.
MSalters
13

Почему использование целочисленного литерала вместо литерала с плавающей запятой вызывает такое поведение?

Потому printf()что не имеет типизированных параметров, кроме const char* formatstringкак 1-й. ...Для всего остального он использует многоточие в стиле c ( ).

Он просто решает, как интерпретировать переданные значения в соответствии с типами форматирования, указанными в строке формата.

У вас будет такое же неопределенное поведение, как при попытке

 int i = 0;
 const double* pf = (const double*)(&i);
 printf("%f\n",*pf); // dereferencing the pointer is UB
πάντα ῥεῖ
источник
3
Некоторые конкретные реализации printfмогут работать таким образом (за исключением того, что переданные элементы являются значениями, а не адресами). Стандарт C не определяет, как printf работают и другие вариативные функции, он просто определяет их поведение. В частности, нет упоминания о кадрах стека.
Кейт Томпсон,
Небольшая придирка: printfесть один типизированный параметр, строка формата, которая имеет тип const char*. Кстати, вопрос помечен как C, так и C ++, и C действительно более актуален; Я бы, наверное, не стал использовать reinterpret_castв качестве примера.
Кейт Томпсон
Просто интересное наблюдение: такое же поведение undefined и, скорее всего, из-за идентичного механизма, но с небольшой разницей в деталях: передача int, как в вопросе, UB происходит в printf при попытке интерпретировать int как double - в вашем примере , это происходит уже снаружи при разыменовании pf ...
Аконкагуа
@Aconcagua Добавил пояснение.
πάντα ῥεῖ
Этот образец кода является UB для строгого нарушения псевдонима, проблемы совершенно отличной от того, о чем задается вопрос. Например, вы полностью игнорируете возможность того, что числа с плавающей запятой передаются целыми числами в разных регистрах.
MM
12

Использование несовпадающего printf()спецификатора "%f"и типа (int) 0приводит к неопределенному поведению.

Если спецификация преобразования недействительна, поведение не определено. C11dr §7.21.6.1 9

Возможные причины УБ.

  1. Это UB по спецификации, а компиляция злобная, - сказал Нуф.

  2. doubleи intбывают разных размеров.

  3. doubleи intмогут передавать свои значения, используя разные стеки (общий или стек FPU ).

  4. A не double 0.0 может быть определен полностью нулевым битовым шаблоном. (редко)

chux - восстановить Монику
источник
10

Это одна из тех прекрасных возможностей узнать о предупреждениях вашего компилятора.

$ gcc -Wall -Wextra -pedantic fnord.c 
fnord.c: In function ‘main’:
fnord.c:8:2: warning: format ‘%f’ expects argument of type ‘double’, but argument 2 has type ‘int’ [-Wformat=]
  printf("%f\n",0);
  ^

или

$ clang -Weverything -pedantic fnord.c 
fnord.c:8:16: warning: format specifies type 'double' but the argument has type 'int' [-Wformat]
        printf("%f\n",0);
                ~~    ^
                %d
1 warning generated.

Таким образом, printfвозникает неопределенное поведение, потому что вы передаете ему несовместимый тип аргумента.

змей
источник
9

Я не уверен, что сбивает с толку.

Ваша строка формата ожидает double; вместо этого вы предоставляете int.

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

Гонки легкости на орбите
источник
3
@Voo: Эта строка формата модификатор является , к сожалению , по имени, но я до сих пор не понимаю , почему вы думаете , что intбыло бы приемлемо здесь.
Lightness Races in Orbit
1
@Voo: "(что также может считаться допустимым шаблоном с плавающей запятой)" Почему шаблон считается intдопустимым шаблоном с плавающей запятой? Два дополнения и различные кодировки с плавающей запятой почти не имеют ничего общего.
Lightness Races in Orbit
2
Это сбивает с толку, потому что для большинства библиотечных функций предоставление целочисленного литерала 0типизированному аргументу doubleбудет работать правильно. Для новичка не очевидно, что компилятор не выполняет такое же преобразование для printfслотов аргументов, адресованных %[efg].
zwol
1
@Voo: Если вас интересует, насколько это может быть ужасно неправильным, учтите, что в x86-64 SysV ABI аргументы с плавающей запятой передаются в другом наборе регистров, чем целочисленные аргументы.
EOF
1
@LightnessRacesinOrbit Я думаю, что всегда уместно обсуждать, почему что-то является UB, что обычно включает разговор о том, какая широта реализации разрешена и что на самом деле происходит в общих случаях.
zwol
4

"%f\n"гарантирует предсказуемый результат только тогда, когда второй printf()параметр имеет тип double. Затем дополнительные аргументы вариативных функций подвергаются продвижению аргументов по умолчанию. Целочисленные аргументы подпадают под целочисленное продвижение, которое никогда не приводит к значениям с плавающей запятой. И floatпараметры повышаются до double.

В довершение всего: стандарт позволяет второму аргументу быть или floatили doubleи ничем другим.

Серджио
источник
4

Почему это формально UB, теперь обсуждается в нескольких ответах.

Причина, по которой вы получаете именно такое поведение, зависит от платформы, но, вероятно, следующая:

  • printfожидает своих аргументов в соответствии со стандартным распространением vararg. Это означает, что a floatбудет a, doubleа все, что меньше an, intбудет int.
  • Вы передаете, intгде функция ожидает double. У вас int, вероятно, 32 бит, ваш double64 бит. Это означает, что четыре байта стека, начинающиеся с того места, где должен располагаться аргумент, есть 0, но следующие четыре байта имеют произвольное содержимое. Это то, что используется для построения отображаемого значения.
glglgl
источник
0

Основная причина этой проблемы с "неопределенным значением" заключается в приведении указателя к intзначению, переданному в printfраздел переменных параметров, к указателю на doubleтипы, которые va_argвыполняет макрос.

Это вызывает ссылку на область памяти, которая не была полностью инициализирована значением, переданным в качестве параметра printf, потому что doubleразмер буферной области памяти больше intразмера.

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


Он может учитывать эти конкретные части реализации полупрофессионального кода «printf» и «va_arg» ...

printf

va_list arg;
....
case('%f')
      va_arg ( arg, double ); //va_arg is a macro, and so you can pass it the "type" that will be used for casting the int pointer argument of printf..
.... 


Реальная реализация в vprintf (с учетом gnu impl.) управления случаем кода параметров с двойным значением:

if (__ldbl_is_dbl)
{
   args_value[cnt].pa_double = va_arg (ap_save, double);
   ...
}



va_arg

char *p = (double *) &arg + sizeof arg;  //printf parameters area pointer

double i2 = *((double *)p); //casting to double because va_arg(arg, double)
   p += sizeof (double);



Ссылки

  1. gnu project glibc реализация printf (vprintf))
  2. пример кода расширения printf
  3. пример кода расширения va_arg
Чиро Корвино
источник