На цьому уроці ми розглянемо типи даних з плаваючою крапкою, їх точність і діапазон, що таке експоненціальний запис і як він використовується, а також розглянемо помилки округлення, що таке nan
і inf
.
Типи даних з плаваючою крапкою
Цілочисельні типи даних відмінно підходять для роботи з цілими числами, але ще є і дробові числа. І для роботи з ними ми можемо використовувати типи даних з плаваючою крапкою (або “типи даних з плаваючою комою”, англ. “floating point”). Змінна такого типу може зберігати будь-які дійсні дробові числа, наприклад: 4320.0
, -3.33
чи 0.01226
. Чому крапка «плаваюча»? Справа в тому, крапка/кома переміщується (ніби «плаває») між цифрами, розділяючи цілу і дробову частини значення.
Є три типи даних з плаваючою крапкою: float, double і long double. Як і з цілочисельними типами, мова C++ визначає тільки їх мінімальний розмір. Типи даних з плаваючою крапкою завжди є signed (тобто можуть зберігати як додатні, так і від’ємні числа).
Тип | Мінімальний розмір | Зазвичай | |
Тип даних з плаваючою крапкою | float | 4 байти | 4 байти |
double | 8 байт | 8 байт | |
long double | 8 байт | 8, 12 або 16 байт |
Оголошення змінних різних типів даних з плаваючою крапкою:
1 2 3 |
float fValue; double dValue; long double dValue2; |
Якщо потрібно використати ціле число зі змінною типу з плаваючою крапкою, то тоді потрібно вказати після розділової крапки нуль. Це дозволяє розрізняти змінні цілочисельних типів від змінних типів з плаваючою крапкою:
1 2 3 |
int n(5); // 5 - це цілочисельний тип double d(5.0); // 5.0 - це тип даних з плаваючою крапкою (за замовчуванням double) float f(5.0f); // 5.0 - це тип даних з плаваючою крапкою, "f" від "float" |
Зверніть увагу, літерали типу з плаваючою крапкою за замовчуванням відносяться до типу double. f
в кінці числа означає тип float.
Експоненціальний запис
Експоненціальний запис дуже корисний для написання довгих чисел в короткій формі. Числа в експоненціальному записі мають наступний вигляд: мантиса х 10експонент
. Наприклад, розглянемо вираз 1.2×104
. Значення 1.2
— це мантиса (або “значуща частина числа”), а 4
— це експонент (або “порядок числа”). Результатом цього виразу є значення 12000
.
Зазвичай в експоненціальному записі в цілій частині знаходиться тільки одна цифра, а всі інші цифри пишуться після розділової крапки (в дробовій частині).
Розглянемо масу Землі. У десятковій системі числення вона представлена як 5973600000000000000000000 кг
. Погодьтеся, що це дуже велике число (навіть занадто велике, щоб поміститися в цілочисельну змінну розміром 8 байт). Це число навіть важко читати (там 19 чи 20 нулів?). Але, використовуючи експоненціальний запис, масу Землі можна представити як 5.9736×1024кг
(що набагато легше сприймається). Ще однією перевагою експоненціального запису є порівняння двох дуже великих або дуже маленьких чисел — для цього досить просто порівняти їх експоненти.
У мові C++ буква е
/Е
означає, що число 10 потрібно піднести до степеню, який слідує за цією буквою. Наприклад, 1.2×104
еквівалентно 1.2e4
, значення 5.9736×1024
ще можна записати як 5.9736e24
.
Для чисел, менших ніж одиниця, експонент може бути від’ємним. Наприклад, 5e-2
еквівалентно 5×10-2
, що, в свою чергу, означає 5/102
або 0.05
. Маса електрона дорівнює 9.1093822e-31 кг
.
На практиці експоненціальний запис може використовуватися в операціях присвоювання:
1 2 3 4 5 |
double d1(5000.0); double d2(5e3); // інший спосіб присвоїти значення 5000 double d3(0.05); double d4(5e-2); // інший спосіб присвоїти значення 0.05 |
Конвертація чисел в експоненціальний запис
Для конвертації чисел в експоненціальний запис необхідно дотримуватися процедури, зазначеної нижче:
Ваш експонент починається з нуля.
Перемістіть розділову крапку (яка розділяє цілу і дробову частини) вліво, щоб зліва від неї залишилася тільки одна ненульова цифра:
кожне переміщення крапки вліво збільшує експонент на 1
;
кожне переміщення крапки вправо зменшує експонент на 1
.
Відкиньте всі нулі перед першою ненульовою цифрою в цілій частині.
Відкиньте всі кінцеві нулі в правій (дробовій) частині, тільки якщо вихідне число є цілим (без розділової крапки).
Розглянемо приклади:
Початкове число: 42030
Переміщуємо розділову крапку на 4 цифри вліво: 4.2030e4
Зліва (в цілій частині) немає нулів: 4.2030e4
Відкидаємо кінцевий нуль в дробовій частині: 4.203e4 (4 значущі цифри)
Початкове число: 0.0078900
Переміщуємо розділову крапку на 3 цифри вправо: 0007.8900e-3
Відкидаємо нулі зліва: 7.8900e-3
Не відкидаємо нулі справа (початкове число є дробовим): 7.8900e-3 (5 значущих цифр)
Початкове число: 600.410
Переміщуємо розділову крапку на 2 цифри вліво: 6.00410e2
Зліва немає нулів: 6.00410e2
Нулі справа залишаємо: 6.00410e2 (6 значущих цифр)
Найголовніше, що вам потрібно запам’ятати — цифри в мантисі (частина перед e
) називаються значущими цифрами. Кількість значущих цифр визначає точність самого значення. Чим більше цифр у мантисі, тим точніше значення.
Точність і діапазон типів з плаваючою крапкою
Розглянемо дріб 1/3
. Десятковий варіант цього числа — 0.33333333333333...
(з трійками до нескінченності). Нескінченне число вимагає нескінченної пам’яті для зберігання, а у нас в запасі, як правило, 4 або 8 байт. Змінні типу з плаваючою крапкою можуть зберігати лише певну кількість значущих цифр, інші — відкидаються. Точність визначає кількість значущих цифр, які представляють число без втрати даних.
Коли ми виводимо змінні типу з плаваючою крапкою, то точність об’єкта cout, за замовчуванням, дорівнює 6
. Тобто на екрані ми побачимо тільки 6 значущих цифр, інші — загубляться, наприклад:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#include <iostream> int main() { float f; f = 9.87654321f; std::cout << f << std::endl; f = 987.654321f; std::cout << f << std::endl; f = 987654.321f; std::cout << f << std::endl; f = 9876543.21f; std::cout << f << std::endl; f = 0.0000987654321f; std::cout << f << std::endl; return 0; } |
Результат виконання програми:
9.87654
987.654
987654
9.87654e+06
9.87654e-05
Зверніть увагу, кожне із вищенаведених значень має тільки 6 значущих цифр (цифри перед e
, а не перед крапкою).
Також в деяких випадках cout сам може виводити числа в експоненціальному записі. Залежно від компілятора, експонент може бути доповнений нулями. Наприклад, 9.87654e+06
— це те ж саме, що і 9.87654e6
(просто з доданим нулем). Мінімальна кількість цифр експонента визначається компілятором (Visual Studio використовує 2, інші компілятори можуть використовувати 3).
Також ми можемо перевизначити точність cout, використовуючи функцію std::setprecision() із заголовку iomanip:
1 2 3 4 5 6 7 8 9 10 11 12 |
#include <iostream> #include <iomanip> // для std::setprecision() int main() { std::cout << std::setprecision(16); // вказуємо точність в 16 цифр float f = 3.33333333333333333333333333333333333333f; std::cout << f << std::endl; double d = 3.3333333333333333333333333333333333333; std::cout << d << std::endl; return 0; } |
Результат виконання програми:
3.333333253860474
3.333333333333333
Оскільки ми збільшили точність до 16, то кожна змінна виводиться з 16 цифрами. Але, як ви можете бачити, вихідні числа мають більше цифр!
Точність залежить від розміру типу даних (в float точність менше, ніж в double) і від значення, яке присвоюється:
точність float: від 6 до 9 цифр (зазвичай 7);
точність double: від 15 до 18 цифр (зазвичай 16);
точність long double: 15, 18 чи 33 цифри (в залежності від того, скільки байт займає тип даних на комп’ютері).
Цей принцип відноситься не тільки до дробових чисел, а й до всіх значень, які мають занадто велику кількість значущих цифр, наприклад:
1 2 3 4 5 6 7 8 9 10 |
#include <iostream> #include <iomanip> // для std::setprecision() int main() { float f(123456789.0f); // змінна f має 10 значущих цифр std::cout << std::setprecision(9); // вказуємо точність в 9 цифр std::cout << f << std::endl; return 0; } |
Результат:
123456792
Але ж 123456792
більше ніж 123456789
, чи не так? Значення 123456789.0
має 10 значущих цифр, але точність float дорівнює 7. Тому ми і отримали інше число, відбулася втрата даних!
Отже, потрібно бути обережними при використанні дуже великих/дуже маленьких чисел зі змінними типу з плаваючою крапкою, які вимагають більшої точності, ніж поточний тип даних може запропонувати.
Діапазон і точність типів даних з плаваючою крапкою, згідно зі стандартом IEEE 754:
Розмір | Діапазон | Точність |
4 байти | від ±1.18 x 10-38 до ±3.4 x 1038 | 6-9 значущих цифр (зазвичай, 7) |
8 байт | від ±2.23 x 10-308 до ±1.80 x 10308 | 15-18 значущих цифр (зазвичай, 16) |
12 байт | від ±3.36 x 10-4932 до ±1.18 x 104932 | 18-21 значущих цифр |
16 байт | від ±3.36 x 10-4932 до ±1.18 x 104932 | 33-36 значущих цифр |
Може здатися трохи дивним, що 12-байтова змінна типу з плаваючою крапкою має такий же діапазон, що і 16-байтова змінна. Це тому, що вони мають однакову кількість біт, виділених для експонента (тільки в 16-байтовій змінній точність буде вище).
Правило: Використовуйте за замовчуванням тип double, замість типу float, так як його точність вище.
Помилки округлення
Розглянемо дріб 1/10
. У десятковій системі числення цей дріб можна представити як 0.1
, в двійковій системі числення цей дріб представлений у вигляді нескінченної послідовності — 0.00011001100110011...
Саме через подібні розбіжності в представленні чисел в різних системах числення, у нас можуть виникати проблеми з точністю, наприклад:
1 2 3 4 5 6 7 8 9 10 11 |
#include <iostream> #include <iomanip> // для std::setprecision() int main() { double d(0.1); std::cout << d << std::endl; // використовуємо точність cout за замовчуванням (6 цифр) std::cout << std::setprecision(17); std::cout << d << std::endl; return 0; } |
Результат виконання програми:
0.1
0.10000000000000001
Перший cout виводить 0.1
(що і очікувано). Після того, як ми змінили точність cout до 17 цифр, ми побачили, що змінна d
— це не зовсім 0.1
! Подібне відбувається через обмеження в кількості виділеної пам’яті для змінних типу double, а також в необхідності “округляти” числа. По факту, ми отримали типову помилку округлення.
Подібні помилки можуть мати несподівані наслідки:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
#include <iostream> #include <iomanip> // для std::setprecision() int main() { std::cout << std::setprecision(17); double d1(1.0); std::cout << d1 << std::endl; double d2(0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1); // повинно бути 1.0 std::cout << d2 << std::endl; } |
Результат виконання програми:
1
0.99999999999999989
Хоча ми очікували, що d1
і d2
виявляться рівними, але це не так. А що, якби нам довелося порівнювати ці змінні і, виходячи з результату, виконувати певний сценарій? В такому випадку помилок нам не минути.
Математичні операції (наприклад, додавання чи множення), як правило, тільки збільшують масштаб цих помилок. Навіть якщо 0.1
має похибку в 17-й значущій цифрі, то при виконанні операції додавання десять разів, помилка округлення переміститься до 16-ї значущої цифри.
nan та inf
Є дві спеціальні категорії чисел типу з плаваючою крапкою:
inf (або “нескінченність”, від англ. “infinity”), який може бути додатним або від’ємним.
nan (або “не число”, від англ. “not a number”). Їх є декілька видів (обговорювати всі види зараз ми не будемо).
Розглянемо приклади на практиці:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
#include <iostream> int main() { double zero = 0.0; double posinf = 5.0 / zero; // додатна нескінченність std::cout << posinf << "\n"; double neginf = -5.0 / zero; // від'ємна нескінченність std::cout << neginf << "\n"; double nan = zero / zero; // не число (математично некоректно) std::cout << nan << "\n"; return 0; } |
Результат виконання програми:
inf
-inf
-nan(ind)
inf
означає “нескінченність”, а ind
означає “невизначений” (від англ. “indeterminate”). Зверніть увагу, результати виводу inf
і nan
залежать від компілятора/архітектури комп’ютера, тому ваш результат виконання вищенаведеної програми може відрізнятися від мого результату.
Висновки
Змінні типу з плаваючою крапкою відмінно підходять для зберігання дуже великих або дуже маленьких (в тому числі і дробових) чисел до тих пір, поки вони мають обмежену кількість значущих цифр (не перевищують точність певного типу даних).
Змінні типу з плаваючою крапкою можуть мати невеликі помилки округлення, навіть якщо точність типу не перевищена. У більшості випадків такі помилки залишаються непоміченими, тому що вони не такі значні. Але слід пам’ятати, що порівняння змінних типів з плаваючою крапкою може мати невизначені наслідки/результати (а виконання математичних операцій з такими змінними може тільки збільшити масштаб цих помилок).
Тест
Запишіть наступні числа в експоненціальному записі в стилі C++ (використовуючи букву е
як експонент) і визначте, скільки значущих цифр має кожне з наступних чисел:
34.50
0.004000
123.005
146000
146000.001
0.0000000008
34500.0
Відповідь
3.450e1
(4 значущі цифри);
4.000e-3
(4 значущі цифри);
1.23005e2
(6 значущих цифр);
1.46e5
(3 значущі цифри);
1.46000001e5
(9 значущих цифр);
8e-10
(1 значуща цифра). Тут мантиса не 8.0, а 8, тому число має тільки 1 значущу цифру;
3.45000e4
(6 значущих цифр). Тут кінцеві нулі не ігноруються, так як в вихідному числі є крапка, яка розділяє цілу і дробову частини. Хоча ця крапка ніяк не впливає на саме число, вона впливає на його точність. Якби вихідне число було вказано як 34500, то відповідь була б 3.45e4
.