4.5 -- Liczby całkowite bez znaku i dlaczego ich unikać

Liczby całkowite bez znaku

W poprzedniej lekcji (4.4 -- Liczby całkowite ze znakiem), omówiliśmy liczby całkowite ze znakiem, które są zbiorem typów, które mogą przechowywać dodatnie i ujemne liczby całkowite, w tym 0.

C++ obsługuje również liczby całkowite bez znaku liczby całkowite. Liczby całkowite bez znaku to liczby całkowite, w których mogą znajdować się tylko nieujemne liczby całkowite.

Definiowanie liczb całkowitych bez znaku

Aby zdefiniować liczbę całkowitą bez znaku, używamy słowa kluczowego niepodpisany . Zgodnie z konwencją, jest on umieszczany przed typem:

unsigned short us;
unsigned int ui;
unsigned long ul;
unsigned long long ull;

Zakres liczb całkowitych bez znaku

1-bajtowa liczba całkowita bez znaku ma zakres od 0 do 255. Porównaj to z 1-bajtową liczbą całkowitą ze znakiem w zakresie od -128 do 127. Obie mogą przechowywać 256 różnych wartości, ale liczby całkowite ze znakiem wykorzystują połowę swojego zakresu dla liczb ujemnych, podczas gdy liczby całkowite bez znaku mogą przechowywać liczby dodatnie, które są dwa razy większe.

Oto tabela przedstawiająca zakres dla liczb całkowitych bez znaku:

Rozmiar/typZakres
8 bitów bez znaku0 do 255
16 bitów bez znaku0 do 65 535
32 bity bez znaku0 do 4 294 967 295
64 bity bez znaku0 do 18 446 744 073 709 551 615

An n-bitowa zmienna bez znaku ma zakres od 0 do (2n)-1.

Gdy nie są wymagane liczby ujemne, liczby całkowite bez znaku dobrze nadają się do sieci i systemów z małą ilością pamięci, ponieważ liczby całkowite bez znaku mogą przechowywać więcej liczb dodatnich bez zajmowania dodatkowej pamięci.

Pamięć o terminach podpisany i bez znaku

Nowym programistom czasami zdarza się pomylić podpis i bez znaku. Poniżej znajduje się opis prosty sposób na zapamiętanie różnicy: aby odróżnić liczby ujemne od dodatnich, używamy znaku ujemnego, zakładamy, że liczba jest dodatnia. W związku z tym liczba całkowita ze znakiem (liczba całkowita ze znakiem) może odróżnić liczbę dodatnią od ujemnej. Liczba całkowita bez znaku (liczba całkowita bez znaku) zakłada, że wszystkie wartości są dodatnie.

Przepełnienie liczby całkowitej bez znaku

Co się stanie, jeśli spróbujemy zapisać liczba 280 (która wymaga 9 bitów do przedstawienia) w 1-bajtowej (8-bitowej) liczbie całkowitej bez znaku? Odpowiedź brzmi: przepełnienie.

Nota autora

Co dziwne, standard C++ wyraźnie mówi, że „obliczenia obejmujące operandy bez znaku nigdy nie mogą zostać przepełnione”. przypadki (cite) Biorąc pod uwagę, że większość programistów rozważyłaby to przepełnienie, nazwiemy to przepełnieniem pomimo stwierdzeń standardu C++, które stanowią inaczej.

Jeśli wartość bez znaku jest poza zakresem, jest ona dzielona przez jeden większy niż największa liczba typu i zachowywana jest tylko reszta.

Liczba 280 jest zbyt duża, aby się zmieścić. w naszym 1-bajtowym zakresie od 0 do 255. 1 większa od największej liczby typu to 256. Dlatego dzielimy 280 przez 256, otrzymując 1 resztę 24. Pozostała część 24 jest przechowywana.

Oto inny sposób myślenia o tym samym Każda liczba większa niż największa liczba reprezentowana przez typ po prostu „zawija się” (czasami nazywana „modulo wrapping”). 255 jest w zakresie 1-bajtowej liczby całkowitej, więc 255 jest w porządku. 256 jednak jest poza zakresem, więc zawija się do wartości 0. 257 zawija się do wartości 1. 280 zawija się do wartości 24.

Przyjrzyjmy się temu za pomocą 2-bajtowe skróty:

#include <iostream>

int main()
{
    unsigned short x{ 65535 }; // largest 16-bit unsigned value possible
    std::cout << "x was: " << x << '\n';

    x = 65536; // 65536 is out of our range, so we get modulo wrap-around
    std::cout << "x is now: " << x << '\n';

    x = 65537; // 65537 is out of our range, so we get modulo wrap-around
    std::cout << "x is now: " << x << '\n';

    return 0;
}

Jak myślisz, jaki będzie wynik tego programu?

(Uwaga: jeśli spróbujesz skompilować powyższy program, kompilator powinien wyświetlić ostrzeżenia o przepełnieniu lub obcięciu — aby uruchomić program, musisz wyłączyć opcję „traktuj ostrzeżenia jako błędy”)

x was: 65535
x is now: 0
x is now: 1

Możliwe jest zawijanie również w innym kierunku. 0 można przedstawić w a 2-bajtowa liczba całkowita bez znaku, więc nie ma problemu. -1 nie jest reprezentowalne, więc zawija się do góry zakresu, tworząc wartość 65535. -2 zawija do 65534. I tak dalej.

#include <iostream>

int main()
{
    unsigned short x{ 0 }; // smallest 2-byte unsigned value possible
    std::cout << "x was: " << x << '\n';

    x = -1; // -1 is out of our range, so we get modulo wrap-around
    std::cout << "x is now: " << x << '\n';

    x = -2; // -2 is out of our range, so we get modulo wrap-around
    std::cout << "x is now: " << x << '\n';

    return 0;
}
x was: 0
x is now: 65535
x is now: 65534

Powyższy kod wywołuje ostrzeżenie w niektórych kompilatorach, ponieważ kompilator wykrywa, że ​​literał całkowity jest poza zakresem dla danego typu. Jeśli mimo wszystko chcesz skompilować kod, tymczasowo wyłącz opcję „Traktuj ostrzeżenia jako błędy”.

Na marginesie…

Wiele znaczących błędów w historii gier wideo miało miejsce w wyniku zawijania zachowań liczbami całkowitymi bez znaku. W grze zręcznościowej Donkey Kong nie można przekroczyć poziomu 22 ze względu na błąd przepełnienia, który pozostawia użytkownikowi niewystarczającą ilość dodatkowego czasu na ukończenie poziomu.

W grze Civilization na komputery PC Gandhi był znany z tego, że często jako pierwszy używał broni nuklearnej, co wydaje się sprzeczne z jego oczekiwaną pasywną naturą. Gracze mieli teorię, że poziom agresji Gandhiego początkowo wynosił 1, ale jeśli wybrałby rząd demokratyczny, otrzymałby modyfikator agresji -2 (obniżając jego obecną wartość agresji o 2). Spowodowałoby to przekroczenie jego poziomu agresji do 255, czyniąc go maksymalnie agresywnym! Jednak niedawno Sid Meier (autor gry) wyjaśnił, że tak nie jest w rzeczywistości.

Kontrowersje wokół liczb bez znaku

Wielu programistów (i niektóre duże firmy programistyczne, takie jak Google) uważa, że programiści powinni generalnie unikać liczb całkowitych bez znaku.

Jest to głównie spowodowane dwoma zachowaniami, które mogą powodować problemy.

Po pierwsze, w przypadku wartości ze znakiem trochę pracy, aby przypadkowo przekroczyć górę lub dół zakresu, ponieważ te wartości są dalekie od 0. W przypadku liczb bez znaku znacznie łatwiej jest przekroczyć dół zakresu, ponieważ dolna część zakresu wynosi 0, czyli blisko miejsca, gdzie znajduje się większość naszych wartości.

Rozważ odejmowanie dwóch liczb bez znaku, takich jak 2 i 3:

#include <iostream>

// assume int is 4 bytes
int main()
{
	unsigned int x{ 2 };
	unsigned int y{ 3 };

	std::cout << x - y << '\n'; // prints 4294967295 (incorrect!)

	return 0;
}

Ty i ja wiemy, że 2 - 3 Jest -1, ale -1 nie może być reprezentowana jako liczba całkowita bez znaku, więc otrzymamy przepełnienie i następujący wynik:

4294967295

Inne częste niepożądane zawijanie ma miejsce, gdy liczba całkowita bez znaku jest wielokrotnie zmniejszana o 1, aż do próby zmniejszenia do liczby ujemnej. Zobaczysz tego przykład po wprowadzeniu pętli.

Po drugie, bardziej podstępne, może wystąpić nieoczekiwane zachowanie, gdy mieszasz liczby całkowite ze znakiem i bez znaku. W C++, jeśli operacja matematyczna (np. arytmetyka lub porównanie) ma jedną liczbę całkowitą ze znakiem i jedną liczbę całkowitą bez znaku, liczba całkowita ze znakiem będzie zwykle konwertowana na liczbę całkowitą bez znaku. A zatem wynik będzie niepodpisany. Na przykład:

#include <iostream>

// assume int is 4 bytes
int main()
{
	unsigned int u{ 2 };
	signed int s{ 3 };

	std::cout << u - s << '\n'; // 2 - 3 = 4294967295

	return 0;
}

To również daje wynik:

4294967295

W tym przypadku, jeśli u został podpisany, zostanie wygenerowany poprawny wynik. Ponieważ jednak u jest bez znaku (co łatwo przeoczyć), s jest konwertowany na wartość bez znaku, a wynik (-1) jest traktowany jako wartość bez znaku. Ponieważ -1 nie można zapisać w wartości bez znaku, otrzymujemy przepełnienie i nieoczekiwaną odpowiedź.

Oto kolejny przykład, w którym coś poszło nie tak:

#include <iostream>

// assume int is 4 bytes
int main()
{
    signed int s { -1 };
    unsigned int u { 1 };

    if (s < u) // -1 is implicitly converted to 4294967295, and 4294967295 < 1 is false
        std::cout << "-1 is less than 1\n";
    else
        std::cout << "1 is less than -1\n"; // this statement executes

    return 0;
}

Wypisuje:

1 is less than -1

Ten program jest dobrze zbudowany, kompiluje się i jest logicznie spójny dla oka. Ale wypisuje błędną odpowiedź. I chociaż Twój kompilator powinien ostrzegać Cię o niezgodności ze znakiem/bez znaku w tym przypadku, Twój kompilator wygeneruje również identyczne ostrzeżenia w innych przypadkach, w których nie występuje ten problem (np. gdy obie liczby są dodatnie), co utrudnia wykrycie rzeczywistego problemu.

Powiązana treść

Omówimy reguły konwersji, które wymagają, aby oba operandy niektórych operacji binarnych były tego samego typu na lekcji 10.5 — Konwersje arytmetyczne.
Instrukcje if omówimy w nadchodzących lekcjach lekcja 4.10 - Wprowadzenie do instrukcji if.

Dodatkowo istnieją inne problematyczne przypadki, które są trudne do wykrycia. Rozważ następujące kwestie:

#include <iostream>

// assume int is 4 bytes
void doSomething(unsigned int x)
{
    // Run some code x times

    std::cout << "x is " << x << '\n';
}

int main()
{
    doSomething(-1);

    return 0;
}

Autor metody doSomething() spodziewał się, że ktoś wywoła tę funkcję wyłącznie z liczbami dodatnimi. Ale rozmówca przechodzi -1 -- ewidentnie błąd, ale mimo to popełniony. Co się stanie w tym przypadku?

Podpisany argument -1 jest niejawnie konwertowany na parametr bez znaku. -1 nie mieści się w zakresie liczby bez znaku, więc zawija się do 4294967295. Wtedy Twój program staje się balistyczny.

Jeszcze bardziej problematyczne jest to, że może być trudno temu zapobiec. O ile nie skonfigurujesz swojego kompilatora tak, aby agresywnie generował ostrzeżenia o konwersji ze znakiem/bez znaku (a powinieneś), twój kompilator prawdopodobnie nawet nie będzie na to narzekał.

Wszystkie te problemy są powszechnie spotykane, powodują nieoczekiwane zachowanie i są trudne do znalezienia, nawet przy użyciu zautomatyzowanych narzędzi zaprojektowanych do wykrywania problemów.

Biorąc pod uwagę powyższe, nieco kontrowersyjną najlepszą praktyką, którą będziemy zalecać, jest unikanie typów bez znaku z wyjątkiem określonych okolicznościach.

Najlepsza praktyka

Preferuj liczby ze znakiem zamiast bez znaku do przechowywania wielkości (nawet wielkości, które powinny być nieujemne) i operacji matematycznych. Unikaj mieszania liczb ze znakiem i bez znaku.

Powiązana treść

Dodatkowy materiał na poparcie powyższych zaleceń (obejmuje również obalenie niektórych powszechnych kontrargumentów):

  1. Interaktywny panel C++ (patrz 9:48-13:08, 41:06-45:26 i 1:02:50-1:03:15)
  2. Indeksy dolne i rozmiary należy podpisywać (od Bjarne Stroustrup, twórcy C++)
  3. Liczby całkowite bez znaku z bloga libtorrent

Kiedy więc używać liczb bez znaku?

Jest jeszcze kilka przypadków w C++, gdzie dozwolone/konieczne jest używanie liczb bez znaku.

Po pierwsze, przy manipulacji bitami preferowane są liczby bez znaku (omówione w rozdziale O - to jest duże „o”, a nie „0”). Są one również przydatne, gdy wymagane jest dobrze zdefiniowane zachowanie zawijania (przydatne w niektórych algorytmach, takich jak szyfrowanie i generowanie liczb losowych).

Po drugie, w niektórych przypadkach nadal nie da się uniknąć użycia liczb bez znaku, głównie tych związanych z indeksowaniem tablicy. Porozmawiamy o tym więcej w lekcjach na temat tablic i indeksowania tablic.

Pamiętaj również, że jeśli tworzysz dla systemu wbudowanego (np. Arduino) lub w innym kontekście z ograniczonym procesorem/pamięcią, używanie liczb bez znaku jest bardziej powszechne i akceptowane (a w niektórych przypadkach nieuniknione) ze względu na wydajność.

guest
Twój adres e-mail nie zostanie wyświetlony
Znalazłeś błąd? Zostaw komentarz powyżej!
Komentarze związane z poprawkami zostaną usunięte po przetworzeniu, aby pomóc zmniejszyć bałagan. Dziękujemy za pomoc w ulepszaniu witryny dla wszystkich!
Awatary z https://gravatar.com/ są połączone z podanym adresem e-mail.
Powiadamiaj mnie o odpowiedziach:  
387 Komentarze
Najnowsze
Najstarsze Najczęściej głosowane
Wbudowane opinie
Wyświetl wszystkie komentarze