CodeGym /Kursy /C++ SELF /Liczby zmiennoprzecinkowe: float vs double

Liczby zmiennoprzecinkowe: float vs double

C++ SELF
Poziom 10, Lekcja 1
Dostępny

1. Po co potrzebne są liczby rzeczywiste

Jeśli do tej pory żyliśmy w świecie liczb całkowitych, to teraz wchodzimy w rzeczywistość, gdzie zdarzają się 2.5 kilometra, 36.6 stopnia, 0.75 współczynnika, 12.5% zniżki i inne „niecałkowite rzeczy”. Typy rzeczywiste w C++ są właśnie przeznaczone do takich wartości. Są podobne do int pod tym względem, że też przechowują liczby i pozwalają na operacje arytmetyczne, ale różnią się tym, że mają ograniczoną precyzję — i trzeba to mieć na uwadze.

Liczby rzeczywiste używamy, gdy ważna jest skala i część ułamkowa, a nie absolutna "dokładność do ostatniego grosza". Na przykład przy obliczaniu średniej prędkości, średniej oceny, procentu postępu, długości, czasu, temperatury, prawdopodobieństw — prawie zawsze używamy double.

Zacznijmy od bardzo prostego demonstratora: jeśli chcemy dzielić „po ludzku”, int szybko przestaje być wygodny.

#include <iostream>

int main() {
    int minutes = 5;
    int people = 2;

    int perPersonInt = minutes / people;       // 2 (część ułamkowa utracona)
    double perPersonDouble = minutes / people; // 2.0 (ale to wciaż int/int!)

    std::cout << perPersonInt << '\n';       // 2
    std::cout << perPersonDouble << '\n';    // 2
}

Zwróć uwagę: nawet double z prawej strony nie pomoże, jeśli wyrażenie pozostanie typu całkowitego. Dlaczego — omówimy dalej.

2. float i double: czym się różnią

float i double to oba typy rzeczywiste, ale mają różną precyzję i zazwyczaj różny rozmiar w pamięci. Choć standard C++ pozostawia niektóre szczegóły implementacji, w praktyce na większości platform odpowiada to IEEE‑754 (w dokumentach standardu C++ często powiązane z ISO/IEC/IEEE 60559).

Mówiąc prosto i praktycznie: float to „szybsze i mniejsze, ale mniej precyzyjne”, a double to „trochę więcej pamięci, ale zwykle bezpieczniej”. W projektach edukacyjnych (i w wielu realnych) zasada jest prosta: domyślnie wybieramy double, a float stosujemy tylko gdy wiemy, po co.

Poniżej — przyjazna tabelka "na palcach" (liczby typowe dla zwykłych PC/laptopów, ale zależą od procesora):

Typ Zwykle rozmiar Zwykle precyzja (cyfry dziesiętne) Kiedy stosować
float
4 bajty ~6–7 cyfr grafika, duże tablice, gdy pamięć ma znaczenie
double
8 bajtów ~15–16 cyfr obliczenia, wzory, wartości średnie, "zwykłe zastosowania"

Charakterystyki na swojej platformie możesz sprawdzić przez std::numeric_limits. To przydatne, żeby nie zgadywać i nie kłócić się z kolegą, ile tam jest cyfr.

#include <iostream>
#include <limits>

int main() {
    std::cout << std::numeric_limits<float>::digits10 << '\n';   // zwykle 6
    std::cout << std::numeric_limits<double>::digits10 << '\n';  // zwykle 15
}

3. Literały i typy wyrażeń

Literały: dlaczego 3.14 to double, a 3.14f to float

Tu początkującym często zdarza się "mały błąd w głowie": piszesz float x = 3.14; i myślisz, że "3.14 to float". Domyślnie literał zmiennoprzecinkowy taki jak 3.14 ma typ double. Literał typu float otrzymujesz, dodając sufiks f lub F.

To nie jest "czepianie się": kiedy typy mieszają się, kompilator robi konwersje, a możesz dostać ostrzeżenie, utratę precyzji lub po prostu nie zrozumieć, co się dzieje.

Pokażmy to na małym przykładzie:

#include <iostream>

int main() {
    float  a = 1.0f / 3.0f;  // float / float -> float (około 0.33333334)
    double b = 1.0  / 3.0;   // double / double -> double

    std::cout << a << '\n';
    std::cout << b << '\n';
}

Kolejny przydatny zapis to notacja wykładnicza. Czyta się ją jako "pomnożyć przez 10 do potęgi".

#include <iostream>

int main() {
    double tiny = 1e-3; // 0.001
    double huge = 2e6;  // 2000000

    std::cout << tiny << '\n'; // 0.001
    std::cout << huge << '\n'; // 2e+06 lub 2000000 (zależy od formatu wyjścia)
}

Typ wyniku zależy od operandów

Jedna z najważniejszych zasad: typ wyniku wyrażenia określają typy operandów występujących w wyrażeniu. Nie to, po lewej, nie to, gdzie przypisujesz, ale jakie typy biorą udział w operacji.

To szczególnie mocno widać przy dzieleniu. Jeśli dzielisz int / int, otrzymasz int (czyli część ułamkowa zostanie odcięta), nawet jeśli potem przypiszesz wynik do double.

#include <iostream>

int main() {
    int a = 5;
    int b = 2;

    double x1 = a / b;                       // 2.0 (najpierw int/int -> 2)
    double x2 = static_cast<double>(a) / b;  // 2.5 (double/int -> double)

    std::cout << x1 << '\n'; // 2
    std::cout << x2 << '\n'; // 2.5
}

Drugi sposób, by "uczynić wyrażenie zmiennoprzecinkowym", to użycie literału zmiennoprzecinkowego:

#include <iostream>

int main() {
    int sum = 5;
    int count = 2;

    double avg = sum / 2.0; // 2.5, bo 2.0 to double
    std::cout << avg << '\n';
}

Dobra nawyk: gdy widzisz dzielenie, zapytaj siebie: "jakie typy mają operandy?". To ratuje przed wieloma dziwnymi wynikami.

Mini-schemat: jak nie stracić części ułamkowej

Aby utrwalić regułę o typie wyrażenia, warto mieć w głowie mały schemat.

Czy mamy dzielenie / ?
    |
    v
Oba operandy int?
    |
   tak ------------------> wynik int (część ułamkowa zostanie odcięta)
    |
   nie
    |
    v
Przynajmniej jeden operand double/float?
    |
   tak ------------------> wynik jest zmiennoprzecinkowy (double/float)

Jeśli chcesz wynik ułamkowy, spraw, by przynajmniej jeden operand był zmiennoprzecinkowy: 2.0, 0.1, static_cast<double>(x).

4. Błąd i jego kumulacja

Dlaczego dziesiętne ułamki "się psują"

Liczby rzeczywiste w pamięci komputera nie są przechowywane jako "idealne matematyczne ułamki", lecz jako przybliżenia binarne. I stąd rodzi się nieprzyjemna, ale szczera rzeczywistość: wiele dziesiętnych ułamków nie da się przedstawić dokładnie w systemie binarnym.

Klasyczny przykład to 0.1. W systemie dziesiętnym to "jedna dziesiąta", a w binarnym to ułamek nieskończony (jak 1/3 w systemie dziesiętnym: 0.3333333...). Dlatego komputer przechowuje "najbliższą możliwą wartość". Zwykle jest tak blisko, że nie przeszkadza… dopóki nie zaczniesz porównań przez == lub wielokrotnego powtarzania operacji.

Mini‑demonstracja:

#include <iostream>

int main() {
    double a = 0.1;
    double b = 0.2;
    double c = a + b;

    std::cout << c << '\n';         // często drukowane jako 0.3
    std::cout << (c - 0.3) << '\n'; // ale tu może być coś jak 5.55112e-17
}

Jeśli widzisz "ładnie wypisane 0.3", to nie oznacza, że wewnętrznie jest to dokładnie 0.3. Format wyjścia może zaokrąglić liczbę do druku. Wartość w pamięci pozostaje tym, czym jest.

Dlaczego w pętlach błąd staje się zauważalny

Jeden mały błąd zwykle nie jest straszny. Ale jeśli wielokrotnie powtarzasz operację (np. dodajesz 0.1 w pętli), możesz nagromadzić błąd tak, że stanie się zauważalny. To nie "błąd twojego kodu", to cecha obliczeń o ograniczonej precyzji.

Szczególnie często występuje to w symulacjach, obliczeniach krokowych czasu, przy sumowaniu wielu małych wartości ułamkowych.

#include <iostream>

int main() {
    float  sf = 0.0f;
    double sd = 0.0;

    for (int i = 0; i < 10; ++i) {
        sf += 0.1f;
        sd += 0.1;
    }

    std::cout << (sf - 1.0f) << '\n'; // może nie być 0
    std::cout << (sd - 1.0)  << '\n'; // też może nie być 0, ale zwykle mniejszy błąd
}

W praktyce to jedna z przyczyn, dlaczego double często jest bezpieczniejszy: ma zwykle większą precyzję i "drgania" pojawiają się później i słabiej.

5. Mini-aplikacja: liczymy tempo i wybieramy double

Teraz dodamy liczby rzeczywiste do naszej konsolowej aplikacji: mały kalkulator tempa biegu. Pomysł jest prosty: użytkownik wpisuje dystans (w kilometrach) i czas (w minutach), a program wypisuje tempo "minuty na kilometr".

Ważne, że dystans może być ułamkowy: 2.5 km, 7.42 km itd. Zatem int nie pasuje — wybieramy double.

#include <iostream>

int main() {
    double distanceKm = 0.0;
    int timeMinutes = 0;

    std::cin >> distanceKm >> timeMinutes;

    double pace = timeMinutes / distanceKm; // int/double -> double
    std::cout << pace << '\n';
}

Teraz specjalnie zrobimy "przeciwprzykład": gdybyśmy próbowali trzymać wszystko w int, musielibyśmy albo zaokrąglać dystans, albo trzymać "metry" jako całkowite. To już inny projekt (czasami poprawny, ale nie dziś). W tej lekcji ważne jest: double upraszcza i naturalnie rozwiązuje zadanie.

Jeszcze jedna uwaga: jeżeli będziesz liczyć średnie tempo z kilku biegów, prawie na pewno dojdzie do sumowania ułamków i obliczania średnich. I znowu domyślnie wygodniej będzie użyć double.

6. Dlaczego częściej wybiera się double

Chciałoby się mieć "magiczny przycisk wyboru typu". W idealnym świecie: naciśniesz double — i wszystko będzie zawsze poprawne. Tak nie ma. Jest jednak praktyczna zasada, która działa zaskakująco często: jeśli nie zajmujesz się grafiką, zadaniami GPU na urządzeniach mobilnych lub gigantycznymi tablicami liczb — wybierz double.

Powody są prozaiczne. Po pierwsze, precyzji float czasem naprawdę brakuje i błędy ujawniają się w niespodziewanych miejscach: wartości "płyną", porównania przy granicy zachowują się dziwnie, suma z wielu ułamków zaczyna się rozjeżdżać. Po drugie, koszt użycia double w wydajności na większości zwykłych CPU dziś zwykle nie jest katastrofalny, szczególnie dla zadań edukacyjnych i większości zastosowań.

Gdy float jest uzasadniony, zwykle ma to związek z tym, że masz bardzo dużo liczb (miliony/dziesiątki milionów) i pamięć/kasze są wąskim gardłem. Albo pracujesz z API/formatami wymagającymi float. W takich sytuacjach float jest świetnym narzędziem. Ale to świadomy wybór, a nie "po prostu tak napisałem".

7. Typowe błędy przy pracy z float i double

Błąd #1: oczekiwać, że double x = a / b; da część ułamkową, jeśli a i b są int.
To klasyka: zmienna po lewej jest rzeczywista, a wynik nadal jest "cały". Powód: dzielenie odbywa się jako int / int, a dopiero potem wynik konwertowany jest na double. Łatwe do naprawienia: upewniaj się, że wyrażenie jest zmiennoprzecinkowe — przez 2.0 lub static_cast<double>(a).

Błąd #2: pisać float x = 1.0 / 3.0; i sądzić, że obliczenia będą w float.
Literały 1.0 i 3.0 to double, więc obliczenia są w double, a potem wynik jest "ściśnięty" do float. Jeśli naprawdę chcesz arytmetyki float (a zazwyczaj nie), użyj 1.0f i 3.0f.

Błąd #3: porównywać wyniki operacji przez == i dziwić się "dziwactwom".
Po operacjach na ułamkach liczba może być "prawie 0.3", ale nie dokładnie. Dlatego == czasem zwróci false, choć logicznie oczekujesz true. Nauczymy się poprawnych porównań z epsilon w następnym wykładzie, a na razie zapamiętaj: dokładna równość wyników obliczeń to rzadkość.

Błąd #4: sądzić, że "jeśli wyjście ładne, to liczba dokładna".
std::cout często drukuje liczbę zaokrągloną, żeby nie przestraszyć użytkownika. To nie gwarantuje dokładności przechowywania. Możesz zobaczyć 0.3, a wewnątrz będzie wartość bardzo bliska 0.3, ale nie równa jej bit po bicie.

Błąd #5: bezmyślnie wybierać float, bo "jest mniejszy".
Tak, jest mniejszy, ale i mniej precyzyjny. Dla większości zadań edukacyjnych i praktycznych double to bezpieczniejszy wybór. float warto stosować, gdy istnieje uzasadnienie: wymagania API, bardzo duże zbiory danych lub jasne zrozumienie dopuszczalnego błędu.

1
Zadanie
C++ SELF, poziom 10, lekcja 1
Niedostępne
Tempo biegowe
Tempo biegowe
1
Zadanie
C++ SELF, poziom 10, lekcja 1
Niedostępne
Procent postępu
Procent postępu
1
Zadanie
C++ SELF, poziom 10, lekcja 1
Niedostępne
Pamięć pomiarów
Pamięć pomiarów
1
Zadanie
C++ SELF, poziom 10, lekcja 1
Niedostępne
Wybór formatu
Wybór formatu
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION