2021-12-21 / Bartłomiej Kurek
Typy danych: raz a skutecznie.

Liczby

W komputerze są tylko liczby. Sama nazwa nam o tym mówi. "Komputacja".
Procesor to zegar, który z jakąś częstotliwością (Hertz, MHz, GHz, ...) zlicza sygnały - prąd jest, lub go nie ma. Nie wnikam tu w istotę napięcia elektrycznego ani jednostkę Volt, rozważam jedynie pojęcie "sygnału".

Media:

  • Kiedy płyta CD/DVD się kręci, to laser albo wpada w wypaloną dziurkę, albo nie. 1 albo 0.
  • Media magnetyczne - "elektromagnetyzm" - przyciąga, lub nie.
  • Pamięci półprzewodnikowe też - "komórki" (bramki logiczne, itd.) mogą być "naładowane prądem" i zwracać sygnał "1", albo być nienaładowane i zwracać "0".

Mamy zatem prąd, albo nie. 1 albo 0. Reszta to zliczanie strumienia. O tym za chwilę.

img-full

Alfabet

Z 26 liter alfabetu łacińskiego możemy ułożyć nieskończenie wiele słów:

a   b   c   d   e   f   ...
aa  ab  ac  ad  ae  af  ...
aaa aab aac aad aae aaf
...
zzu zzv zzw zzx zzy zzz ...
aaaa aaab ...
....

Alfabet liczb: dziesiętnie

W systemie dziesiętnym mamy 10 cyfr (0 - 9), a możemy stworzyć nieskończenie wiele liczb.
Możemy zadać np. pytanie "ile ziaren piasku zmieści się w Universum?" i próbować na nie odpowiedzieć.
Autor tego dzieła użył podstawy dziesiętnej i musiał "wymyślić" liczby większe niż używane w ówcześnie otaczającej go rzeczywistości (np. liczba owiec w stadzie, liczba ludzi w mieście, itp.). Za jego czasów była "miriada", czyli 10 tysięcy. Resztę musiał wymyślić.
I wymyślił: system pozycyjny i potęgowanie.

Dzisiaj z całego "alfabetu" 10 cyfr tworzymy liczby od od -∞ do +∞. Sprytnie.
Potęgowanie (powtórzone mnożenie) to też potężne narzędzie.

>> 10
10
>>> 10 * 10
100
>>> 10 * 10 * 10
1000

To samo wyrażone potęgowaniem (specjalnie odwracam kolejność):

>>> [10 ** 2, 10 ** 1, 10 ** 0]
[100, 10, 1]

Cokolwiek "do potęgi 0" daje 1. Zatem liczba 123 to:

>>> [(10 ** 2) * 1, (10 ** 1) * 2, (10 ** 0) * 3]
[100, 20, 3]
>>> sum([(10 ** 2) * 1, (10 ** 1) * 2, (10 ** 0) * 3])
123

"Sto dwadzieścia trzy" - tak czytają słowianie (wyjątek: Słowenia). W językach germańskich częściowo liczby czyta się czasem od prawej (np. 23 to "dreiundzwanzig"). Podstawy są różne, choćby "quatre-vingt-dix-neuf", czyli: 4 * 20 + 10 + 9, jak czytają Francuzi liczbę 99, co ujawnia historyczne użycie systemu liczbowego o podstawie 20.

W komputerach mamy różne architektury - jedne interpretują liczby od prawej, inne od lewej. Są też pewne standardy i protokoły: jeśli moja maszyna interpretuje od lewej, a Twoja od prawej, to jak wysyłamy bajty "po sieci", jak się umawiamy?

Alfabet liczb: dwójkowo

W systemie dwójkowym zasady są te same: system pozycyjny, potęgowanie.
Tu z "alfabetu" - choć ma jednak tylko dwie cyfry (0 i 1) - również możemy w ten sam sposób tworzyć nieskończenie wielkie liczby. Wchodzimy zatem w świat binarny przez analogię do znanego nam systemu dziesiętnego.

W systemie dziesiętnym ("base 10") podstawą jest 10 cyfr, czyli przykładowo mając 4 pozycje:

>>> [(10 ** 4), (10 ** 3), (10 ** 2), (10 ** 1), (10 ** 0)]
[10000, 1000, 100, 10, 1]

>>> base = 10
>>> [(base ** 4), (base ** 3), (base ** 2), (base ** 1), (base ** 0)]
[10000, 1000, 100, 10, 1]

>>> sum([(base ** 4), (base ** 3), (base ** 2), (base ** 1), (base ** 0)])
11111

Im więcej pozycji, tym większa potęga, a tym samym większa liczba możliwości (permutacji z powtórzeniami).

Teraz identycznie analizujemy system dwójkowy (podstawą teraz jest dwójka /tyle jest cyfr w "alfabecie"/:

>>> [(2 ** 4), (2 ** 3), (2 ** 2), (2 ** 1), (2 ** 0)]
[16, 8, 4, 2, 1]
>>> base = 2
>>> [(base ** 4), (base ** 3), (base ** 2), (base ** 1), (base ** 0)]
[16, 8, 4, 2, 1]
>>> sum([(base ** 4), (base ** 3), (base ** 2), (base ** 1), (base ** 0)])
31

32 możliwości, pamiętajmy o zerze, bez zera nie ma systemu pozycyjnego (jaki znamy obecnie).
Bez zera nie dałoby się zapisać "po naszemu" masy elektronu, ani wyrazić liczby ziaren piasku w Universum.

Im większa potęga, tym większa liczba.

>>> sum([x ** 7, x ** 6, x ** 5, x ** 4, x ** 3, x ** 2, x ** 1, x ** 0])
255

256 możliwości na 8 bitach (0 - 7):

>>> len([0, 1, 2, 3, 4, 5 , 6, 7])
8

Strumienie bitów, bajty, słowa

A zatem komputer to "liczydło", które zlicza sygnały (jest prąd, albo nie ma). Prąd "płynie" bardzo szybko, zatem możemy sobie wyobrazić, że te jedynki i zera "lecą strumieniami":

0101010011010101011010101010101101010101101010101101010101010110101010101010...

a my chcemy to jakoś interpretować.

Jedna pozycja to bit (dwie możliwości: 0 albo 1).
8 pozycji bitów to bajt (256 możliwości):

>>> 2 ** 8
256
>>> bin(2 ** 8)
'0b100000000'
>>> bin(2 ** 8 - 1)
'0b11111111'

Kiedy chcemy wyrazić większą liczbę - musimy mieć więcej pozycji.

>>> 2 ** 9
512
>>> 2 ** 10
1024
>>> 2 ** 11
2048
>>> 2 ** 12
4096

>>> 2 ** 16  # 64K
65536
>>> 2 ** 32
4294967296   # 4G

Komputery to maszyny liczące, które "rozumieją" słowa maszynowe (instrukcje), czyli jakąś informację zapisaną na określonej liczbie bitów. Istnieją komputery 4 bitowe, 8 bitowe, 16 bitowe, 32 bitowe, 64 bitowe, itd.
Szesnastobitowy system nie może wyrazić liczby większej niż 2 ** 16 (64 kilo), a system 32bit nie będze obsługiwał (w skrócie*) więcej niż 4G pamięci, gdyż nie będzie mógł podać takiego adresu - liczby, która przekracza zakres 2 ** 32.

Liczby ponownie

W komputerze są tylko liczby. Możemy jednak przyporządkować liczbom znaczenie, np. literki.
Mając 8 bitów możemy przyporządkować 2 ** 8 możliwości, czyli 256 różych znaków.
Przykład:

>>> chr(13)  # [enter] (return)
'\r'
>>> chr(65)
'A'
>>> chr(66)
'B'
>>> chr(67)
'C'

I dalej:

>>> chr(97)
'a'
>>> chr(98)
'b'
>>> chr(99)
'c'
>>> ...
Ellipsis
>>> chr(256)
'Ā'
>>> chr(257)
'ā'

Za 255 zaczynają się "krzaki". To znaki, które wymagają więcej niż jednego bajtu (są przyporzadkowane do większych liczb w danym kodowaniu znaków). Alfabety są różne - niektóre (np. koreańskie) mają po kilkadziesiąt tysięcy znaków. Ten podstawowy (łaciński, a w Ameryce "ASCII" /American Standard Code for Information Interchange/) to te 256 znaków, które możemy przypisać liczbom wyrażalnym na jednym bajcie.

Te 256 znaków można sobie szybko wyświetlić:

>>> [(i, chr(i)) for i in range(2 ** 8)]

Literki

Nie ma literek. Są tylko liczby. Krótka zabawa w cenzurę, szyfrowanie:

>>> [ord(c) for c in "bartek"]
[98, 97, 114, 116, 101, 107]
>>> sum([ord(c) for c in "bartek"])
633

... a liczba jego to 666:

>>> 666 - sum([ord(c) for c in "bartek"])
33
>>> chr(33)
'!'
>>> sum([ord(c) for c in "bartek!"])
666

Nero, Neron.

Typy danych: początek

Tak wygląda zapis binarny liczby 97.

>>> bin(97)
'0b1100001'

Zinterpretujmy ten ciąg bitów jako liczbę:

>>> int(bin(97), base=2)
97

A może chodzi o literkę?

>>> chr(int(bin(97), base=2))
'a'

Musimy wiedzieć jak interpretować wartość.

Typy danych: strumień

Dla przykładu: komputer wczytał 32 bity danych:

>>> import random
>>> "".join([str(random.choice([0, 1])) for x in range(32)])
'00110101000110010011100110110000'

Mym więc strumień, który musimy zinterpretować. Pytanie: jakie informacje zapisane są na tych 32 bitach?
Czy to jedna liczba 32 bitowa?

>>> int("0b" + "00110101000110010011100110110000", base=2)
890845616

A może 2 liczby po 16 bitów?

>>> int("0b" + "00110101000110010011100110110000"[:16], base=2)
13593
>>> int("0b" + "00110101000110010011100110110000"[16:], base=2)
14768

A może 4 liczby po 8 bitów?

>>> int("0b" + "00110101000110010011100110110000"[:8], base=2)
53
>>> int("0b" + "00110101000110010011100110110000"[8:16], base=2)
25
>>> int("0b" + "00110101000110010011100110110000"[16:24], base=2)
57
>>> int("0b" + "00110101000110010011100110110000"[24:32], base=2)
176

A może chodziło o 4 liczby 8 bitowe (bajty) jako znaki ("literki")?

>>> chr(int("0b" + "00110101000110010011100110110000"[:8], base=2))
'5'
>>> chr(int("0b" + "00110101000110010011100110110000"[8:16], base=2))
'\x19'
>>> chr(int("0b" + "00110101000110010011100110110000"[16:24], base=2))
'9'
>>> chr(int("0b" + "00110101000110010011100110110000"[24:32], base=2))
'°'

A może dwa znaki dwubajtowe, a może...

Typy danych

Typ danych odzwierciedla liczbę bitów danej informacji. Mamy typy jednobajtowe, dwubajtowe, itd.
Architektura maszyn (procesorów) i standardy języków programowania określają ile bitów na danej architekturze ma dany typ. Tutaj wchodzimy w poważne rzeczy, więc "odpalam" C. Uwaga.

#include <stdio.h>

int main()
{
    printf("'char' : %ld\n", sizeof(char));
    printf("'short': %ld\n", sizeof(short));
    printf("'int'  : %ld\n", sizeof(int));
    printf("'long' : %ld\n", sizeof(long));

    return 0;
}

Kompiluję, uruchamiam, patrzymy:

$ cc types.c -o types -Wall -Werror
$ ./types
'char' : 1
'short': 2
'int'  : 4
'long' : 8

Opisuje to standard języka C (tabelka: Wikipedia).

  • char - 1 bajt (8 bitów)
  • short - 2 bajty, (2 * 8 == 16bit)
  • int - 4 bajty (4 * 8 == 32bit)
  • long - 8 bajtów (8 * 8 == 64bit).

Typy danych to pewien abstrakt. Tym co je łączy jest pojęcie rozmiaru. Każdy typ danych w komputerze ma jakiś rozmiar (size). Wszystkie wyrażają liczbę bitów, sygnałów zliczanych w pewnych odcinkach czasu przez zegar (procesor).
Można dojść do wniosku, że liczby nie istnieją, jest tylko proces zliczania - kalkulacja, kalkulator, komputacja, komputer.

Nutka filozoficzna.
O to czy liczby istnieją, czy też nie, spierają się filozofowie od zarania dziejów.
Czy istnieją tylko wtedy, kiedy w rzeczywistości istnieją odpowiadające im obiekty (ja, my, Trzy Korony), czy istnieją też bez tych obiektów (gdzie istnieją? kiedy? skąd? bez nas?). A jeszcze inni twierdzą, że liczby to fałsz, ale użyteczny fałsz.
Ja zgadzam się z nimi wszystkimi... Jestem ja, jesteś Ty, są oni, gdzieś tam jest 10 ** ∞ gwiazd, a wśród nich takie, które już/jeszcze nie istnieją.

Typy znikają

W skompilowanym programie już nie ma typów. W programie binarnym są same liczby. C nie jest językiem dynamicznym, jak np. Python, który w runtime wie co jest jakim typem (sam "typ" jest obiektem, na którym możemy operować, ale o tym za chwilę).
Zatem - jeśli czytamy jakieś dane, to nasz program musi je umieć zinterpretować. Dlatego w językach programowania używamy typów. Wyrażamy przez nie i śledzimy liczbę bajtów do zinterpretowania, a tym samym możemy instruować komputer o co nam chodzi. Jeśli nie mamy typów, to musimy sami w głowie liczyć, albo wysyłać sygnały (np. Morse code, w nim nie ma typów, są tylko sygnały... ok - jest sygnał "krótki" i sygnał "długi").

Typy zdefiniowane przez użytkownika

Możemy mieć typy złożone - struktury (uwaga, wracamy do C):

#include <stdio.h>

#pragma pack(1)

struct Animal {
    unsigned short age;                /* unfortunately */
    unsigned long  total_brain_cells;  /* hopefully */
    unsigned long  total_neurons;      /* definitely */
};


int main()
{
    printf("'sizeof(unsigned short)'   : %lu\n", sizeof(unsigned short));
    printf("'sizeof(unsigned long)'    : %lu\n", sizeof(unsigned long));

    /* Animal: */
    printf("'sizeof(Animal)'           : %lu\n", sizeof(struct Animal));

    return 0;
}

Używam tutaj dyrektywy #pragma pack(1). Bez niej kompilator próbowałby mi usprawniać program wyrównując rozmiar struktury do rozmiaru int (kompilatory bywają takie sprytne). Komputerowi wprawdzie najlepiej idzie liczenie na typach całkowitych, kiedy nie musi dzielić słów na mniejsze. Tutaj jednak demostruję coś ważnego, wymagam zatem konkretnie unsigned short i to ja decyduję.

Kompiluję, uruchamiam, dumamy.

$ cc types.c -o types -Wall -Werror
$ ./types
'sizeof(unsigned short)'   : 2
'sizeof(unsigned long)'    : 8
'sizeof(Animal)'           : 18

Struktura Animal powyżej definiuje nowy typ danych. Mogę tworzyć zmienne tego typu. Ten typ zdefiniowałem sam, więc jest "typem zdefiniowanym przez użytkownika". Jak każdy typ - ma jakiś rozmiar. Tutaj rozmiar jest sumą typów składowych (sizeof(unsigned short) + sizeof(unsigned long) + sizeof(unsigned long)), czyli: 2 + 8 + 8 = 18 bajtów.

Strumienie bajtów - serializacja, deserializacja

Zapiszmy teraz strumień danych do pliku (bajty z pamięci wprost do pliku).

Najpierw kod i już tłumaczę...

#include <stdio.h>

#pragma pack(1)

struct Animal {
    unsigned short age;                // unfortunately
    unsigned long  total_brain_cells;  // hopefully
    unsigned long  total_neurons;      // definitely
};


int main()
{
    struct Animal popo = { 7, 1234567890, 1234567890987654321};

    FILE *fh = fopen("popo.bin", "wb");
    fwrite((void*)&popo, sizeof(struct Animal), 1, fh);
    fclose(fh);

    return 0;
}

Jest struktura Animal (typ danych, klasa). Tworzę obiekt zwierzątka: popo. Popo ma 7 lat, 1234567890 komórek mózgowych i 1234567890987654321 neuronów. Otwieram plik (fopen()) o nazwie popo.bin w trybie do zapisu w binarnego b.
Wpisuję (fwrite()) strumień danych spod adresu w pamięci, gdzie popo się zaczyna (po to ta "kaczka" w &popo), podaję ile tych bajtów chcę zapisać (sizeof(struct Animal) /tyle ile "waży" popo/), informuję, że jest tylko jeden popo (wielokrotność tego rozmiaru w bajtach (1 raz)), a otwarty wcześniej plik, do którego to ma trafić wskazuje fh.

Kompiluję, uruchamiam, patrzymy:

$ cc types.c -o types -Wall -Werror
$ ./types
$

Otrzymujemy zatem plik binarny z danymi popo (18 bajtów z pamięci, tak jak miało być):

$ file popo.bin
popo.bin: data
$ stat -c "%n: %s" popo.bin
popo.bin: 18

Plik ma 18 bajtów, a zatem nie ma tam już informacji o typach. W pliku są jedynie bajty.

Ten plik zawiera jakieś liczby... Tutaj wypisane są jako liczby w systemie o podstawie 16 (hex).

$ hexdump popo.bin
0000000 0007 02d2 4996 0000 0000 1cb1 b16c 10f4
0000010 1122
0000012

0x12 (1 * 16 + 2) to to samo co 18 dziesiętnie:

>>> 0x12
18

Nie wiedząc co to za bajty nie bylibyśmy w stanie odtworzyć tych danych. Przykładowo spróbuję sqlite3:

$ sqlite3 popo.bin 
SQLite version 3.36.0 2021-06-18 18:36:39
Enter ".help" for usage hints.
sqlite> .schema
Error: file is not a database

File is not a database.

... albo wyświetlić ten plik jako grafikę:

$ qiv popo.bin 
qiv: cannot load any images.
qiv (Quick Image Viewer) v2.3.2
Usage: qiv [options] files ...
See 'man qiv' or type 'qiv --help' for options.

To również się nie udało. Nie uda się go też odtworzyć jako film... itd.

Do odczytania i właściwego zinterpretowania tych danych musimy mieć program, który umie odpowiednio zinterpretować te bajty. Moglibyśmy napisać program, który wczyta kolejno bajty do trzech różnych zmiennych o właściwych typach:

  • unsigned short /age/
  • unsigned long /total_brain_cells/
  • unsigned long /total_neurons/

Możemy jednak wczytać cały strumień bajtów od razu do zmiennej typu Animal. W końcu to "pełnoprawny" typ danych, z czegoś się składa, ma więc rozmiar obliczalny (operatof sizeof) dla kompilatora (suma typów składowych struktury). Wszyscy doskonale wiedzą, że Animal ma 18 bajtów i tyle ma ważyć popo.

A zatem do dzieła! Wracamy do C. Program różni się nieznacznie. Podświetlę różnice:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>

#pragma pack(1)

struct Animal {
    unsigned short age;                // unfortunately
    unsigned long  total_brain_cells;  // hopefully
    unsigned long  total_neurons;      // definitely
};


int main()
{
    struct Animal popo;

    FILE *fh = fopen("popo.bin", "r");
    fread((void*)&popo, sizeof(struct Animal), 1, fh);
    fclose(fh);

    printf("popo.age               :    %d\n",  popo.age);
    printf("popo.total_brain_cells :    %ld\n", popo.total_brain_cells);
    printf("popo.total_neurons     :    %ld\n", popo.total_neurons);

    return 0;
}
  • w linii 14 deklaruję zmienną popo typu Animal, ale nie przypisuję żadnej wartości (nie inicjalizuję, po prostu chcę mieć tyle bajtów zarezerwowanych w pamięci, żeby zmieści tam popo)
  • W linii 17 zmieniłem fwrite() na fread()
  • w liniach 20-22 wypisuję co wczytałem z pliku popo.bin.

Kompiluję, uruchamiamy i witamy Popo ponownie:

$ cc types.c -o types -Wall -Werror 
$ ./types 
popo.age               :    7
popo.total_brain_cells :    1234567890
popo.total_neurons     :    1234567890987654321

Hello Popo.

Znaczna część czytelników zauważy na pewno analogię i nomenklaturę:

  • C++ - iostream (std::cout, std::cin, std::ifstream, std::ostream, itd.)
  • Java - java.io.FileOutputStream, java.io.FileInputStream
  • Python - StreamReader, StreamWriter, io.BytesIO

czy choćby w shell:

$ cat popo.bin popo.bin > two-popos.bin

Strumienie bajtów.

Podsumowanie przykładów, wnioski, dedukcja

Typy danych są informacją dla nas w kodzie, abyśmy mogli wyrażać rozmiary w bajtach, oraz dla kompilatora, aby pilnował tych rozmiarów podczas translacji naszych zapisków na kod maszynowy. Typy to koncept. W językach dynamicznych (np. Python) typy danych to obiekty tworzone podczas działania programu. W takich językach można często na typach operować (ot, "introspekcja" Python). Wracając do języków kompilowanych - warto wspomnieć, że struktury i klasy to to samo. W klasach mamy jeszcze metody, które tak naprawdę są wskaźnikami w strukturach wskazujących obszary pamięci gdzie umieszczony jest kod. Kod to instrukcje, a instrukcje to liczby. Funkcje są blokami pamięci, a nazwy funkcji adresami tych bloków kodu.

Jak to wszystko działa?
Komputer wywołując funkcję (w skrócie) wrzuca na stos argumenty funkcji (jeśli są, /PUSH/) i skacze do adresu tej "etykiety" bloku (nazwa funkcji). W tym bloku są już dalsze instrukcje, m.in. odbieranie argumentów ze stosu (POP), reszta kodu funkcji, na koniec małe sprzątanie i powrót (RETURN).
Kiedy mamy duże dane, to nie przekazujemy ich przez stos. Umieszczamy je gdzieś w pamięci (operator new, malloc()), a na stos trafia wskaźnik (pointer na adres) lub bezpośrednio adres tej pamięci (referencja), gdzie obiekt jest umieszczony. Kiedy już obiektu nie potrzebujemy to zwalniamy pamięć operatorem delete (free()).

Instrukcje w komputerze to liczby różnych rozmiarów. Procesor ma zaimplementowanych wiele instrukcji i posiada też dekoder tychże instrukcji (opcodes). My przekazujemy strumień sygnałów (jak w Morse code /kropka, kreska, .../) a komputer sobie zlicza bajty instrukcji i argumentów.

W "modelowym"/abstrakcyjnym komputerze moglibyśmy założyć np. że procesor interpretuje instrukcje jako liczby według poniżej tabelki:

| DEC |      BIN | INSTR | Opis         |
|-----+----------+-------+--------------|
|   0 | 00000000 | RST   | reset        |
|   1 | 00000001 | ADD   | dodawanie    |
|   2 | 00000010 | SUB   | odejmowanie  |
|   3 | 00000011 | MUL   | mnożenie     |
|   4 | 00000100 | DIV   | dzielenie    |
|   5 | 00000101 | MOV   | przenoszenie |
|   6 | 00000110 | JMP   | skok         |
|   7 | 00000111 | RET   | powrót       |

I tak właśnie działają komputery ("kalkulatory"). Dane i instrukcje są na stosie, procesor je ściąga ze stosu i wykonuje jedna za drugą. Pomijając wszelkie niuanse (cache, itp.) można to przyrównać do "taśmociągu" - na taśmie jadą kolejne zadania, z taśmy zadania ściąga operator i operuje według znanych sobie precedur.
Wszystko "wyżej" to wygodna abstrakcja, tak samo jak i liczby, literki, czy kolorowe piksele (RGB - 8 + 8 + 8 bitów = "paleta kolorów 24bit").

Abstrakcja ma oczywiście swoje cele. Wracając do przykładu liczb (bajtów) interpretowanych od lewej lub prawej - jeśli moja maszyna interpretuje od lewej, a Twoja od prawej, to ja pisząc programy chcę pisać "ADD x y", a niech już jakiś "tłumacz" (kompilator) zamienia to na Twojej maszynie na liczby po Twojemu, a u mnie po mojemu. Twój procesor może mieć inne przyporządkowanie instrukcji (i ich zestaw) niż mój procesor. Mogą też mieć inne architektury (np. 32bit vs 64bit). Posiadając takie przyporządkowanie - jak w przykładowej tabelce powyżej - osiągamy przenośność programów, a sam kod pisze się łatwiej.

Typy: filozoficznie (skąd?), dziedziczenie

W rzeczywistym świecie klasyfikujemy obiekty i pojęcia. W matematyce zajmuje się tym "teoria zbiorów".
Mamy różne zbiory (klasy) liczb: liczby naturalne, całkowite, rzeczywiste, zespolone, itd.
Bardzo ważną rolę w tej dziedzinie odegrał Georg Cantor.
Myślał on dużo o nieskończoności. Zauważył - między innymi - że istnieją różne nieskończoności - większe (obszerniejsze) i mniejsze. Przykładowo liczb naturalnych {1, 2, 3, 4, ..., ∞} jest nieskończenie wiele. Ale tam pomiędzy 3 i 4 jest np. liczba π.
Udowodniono, że π nigdy się "nie kończy", jest "transcendentna". Pomiędzy 1 a 2 jest np. pitagorejski "pierwiastek z dwóch" (2 ** (1/2)). Takich liczb jak π czy właśnie sqrt(2) może w tych przedziałach być nieskończenie wiele, a same w sobie mogą być nieskończone. A zatem klasa liczb "rzeczywistych" posiada "wyższy stopień" nieskończoności w porównaniu do klasy liczb naturalnych. Pomiędzy każdą parą liczb naturalnych jest nieskończenie wiele liczb rzeczywistych.
W historii dziedziny teorii zbiorów pojawia się też logika matematyczna, polemika i postać Bertranda Russella, który odkrył pewien paradoks.
Paradoks Russella jest sformułowany krótko i treściwie:

Mamy zbiór R, który zawiera wszystkie zbiory niezawierające samych siebie.

Jeżeli zbiór R NIE zawiera siebie, to z definicji     należy do zbioru R - sprzeczność.
Jeżeli zbiór R     zawiera siebie, to z definicji NIE należy do zbioru R - sprzeczność.

Ciężki problem... zostawmy go tęgim głowom, a dodajmy jedynie, że na bazie tych rozważań powstała teoria typów. My wracamy zatem do programowania, zostawiamy język C i lądujemy jeszcze na chwilę w interpreterze języka Python.
Rozważamy klasy i dziedzieczenie klas (typów). Najpierw sam typ:

>>> type
<class 'type'>

Klasa: typ.

W programowaniu obiektowym istnieje koncept dziedziczenia. To w zasadzie rozszerzanie zbiorów.
Na "obrazku" wygląda to mniej więcej tak:

      +----------------------------+
      |                            |
      |  +--------+                |
      |  |        |                |
      |  |  A     |                |
      |  +--------+                |
      |                  B         |
      |                            |
      +----------------------------+

B rozszerza A. W języku Java, czy w PHP:

class A {}
class B extends A {}

W C++:

class A {};
class B : public A {};

W Python:

class A:
    ...


class B(A):
    ...

Dokładnie to samo. Wrzucam to w interpreter Python.

>>> class A: ...
... 
>>> class B(A): ...
... 
>>> 

Sprawdzamy czy B jest subklasą A (czy B dziedziczy A):

>>> issubclass(B, A)
True

Z ciekawości sprawdzamy czy A jest subklasą A:

>>> issubclass(A, A)
True

A teraz zadajemy pytanie: czego subklasą jest A? Wiemy, że w Python wszystko jest obiektem (dziedziczy z takiego globalnego object, dzięki czemu (tak w skrócie) "wszystko jest słownikiem").
Sprawdzamy:

>>> issubclass(A, object)
True

To czego subklasą jest object? Jakiego w ogóle typu jest ten object?

>>> type(object)
<class 'type'>
>>> issubclass(object, type)
False

FALSE?! Nie ma nic "wyżej"?

Oczywiście - object nie jest tutaj typem, a obiektem (instancją, konkretnym egzemplarzem).

>>> isinstance(object, type)
True
>>> issubclass(type(object), type)
True

Zatem obiekt jest obiektem typu "type". Ale sam "type" jest klasą! Więc sprawdźmy jakiego typu jest typ:

>>> type(type)
<class 'type'>
>>> type(type(type))
<class 'type'>
>>> type(type(type(type)))
<class 'type'>

Tutaj docieramy do "rekurencyjnego absolutu" i urywamy temat. Do zagadnienia rekurencji wrócimy w innej serii artykułów. Sama logika i ciekawostki filozoficzno-programistyczne również będą się niekiedy pojawiać w artykułach. Liczby są domeną kalkulacji, a tutaj już blisko do wiedzy (Wikipedia: "from Greek: μάθημα, máthēma, 'knowledge, study, learning'") i filozofii natury φύσις.

Podsumowanie

Typy danych są - według mnie - jedną z najistotniejszych spraw w programowaniu. Programy piszemy w jakimś celu. Coś mają robić, coś liczyć, coś przetwarzać. Kiedy dają nam wyniki (obliczenia) lub wykonują akcje, to chcemy, aby wyniki i akcje były bezbłędne.

W jednym z poprzednich artykułów napomknąłem, że warto wiedzieć czym są typy danych.
W tym samym artykule (tutaj) ograniczyłem się do podstawowych pytań retorycznych. Rozwijając nieco teraz - czasem ludzie, którym pomagam w nauce, pytają mnie o różne aspekty rozmów kwalifikacyjnych. Często chcą wiedzieć "jakie pytania są na rozmowach", "jak to wygląda" w moim przypadku, itp. O samym podejściu, własnym doświadczeniu i przydatnych sugestiach w kwestii "interview" napiszę innym razem.
Niemniej, ludziom pytającym mnie o te sprawy odpowiadam czasem - przyznaję, nieco przekornie - w taki sposób:
Hmmm... Gdybyś trafił na mnie, to zadałbym jakieś banalne pytania, np.:

  • Czym jest komputer?
  • Jak działa komputer? Wciskam "power" i co się dzieje?
  • Czym są typy danych i po co w ogóle są?
  • Co typy danych mają ze sobą wspólnego?
  • Gdzie są zapisane te "jedynki i zera"?
  • Czym są instrukcje w komputerze?
  • Jakie znasz typy danych? Czym są typy zdefiniowane przez użytkownika?
  • Dlaczego listy/tablice indeksujemy od 0?
  • Co Cię fascynuje w komputerach/programowaniu?
  • Jakie programy piszesz dla siebie?
  • Czy umieszczając w kodzie napisy stosujesz cudzysłowy czy apostrofy? 1

Oczywiście - należy sobie uświadomić, że wiedza to wiedza, a w pracy wymagany jest też skillset (praktyka i umiejętności).

Do ostatniego pytania o "double quotes"/'single quotes' nie może zabraknąć kodu (C/C++):

#include <stdio.h>

int main()
{
    printf("'a':   %ld\n", sizeof('a'));
    printf("\"a\":   %ld\n", sizeof("a"));
}

Kompiluję (C), uruchamiam, dumamy:

$ cc sizeof.c -o program -Wall -Wextra
$ ./program
'a':   4
"a":   2

Ten sam plik, ten sam kod: kompiluję (C++), uruchamiam, dumamy:

$ c++ sizeof.c -o program -Wall -Wextra
$ ./program
'a':   1
"a":   2

Odpowiedź można wydedukować samemu, a w razie czego sięgnąć do historii u samych źródeł -
"The Development of the C Language*", Dennis M. Ritchie:
/usr/dmr/www/chist.html.

Konsekwencję wyboru stylistycznego widać np. tutaj:

>>> "hello"
'hello'
>>> print("hello")
hello
>>> repr("hello")
"'hello'"
>>> repr('hello')
"'hello'"
>>> print(repr("hello"))
'hello'
>>> print(repr('hello'))
'hello'

Być może to kwestia stylistyczna, zgadzam się, ale z pewnym ALE...

'don\'t do that', "you don't have to".
strings are strings\0.
chars are bytes.
bytes are ints.
'!'
"!!!"

  1. Pomijam sytuacje, w których wyłączenie interpolacji zmiennych jest konieczne (np. niekiedu w shellu).