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ć |
|---|---|---|---|
|
4 bajty | ~6–7 cyfr | grafika, duże tablice, gdy pamięć ma znaczenie |
|
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.
GO TO FULL VERSION