Zadania

Dla zadania algorithm.h zdefiniowano pakiet testów: test_algorithm.c. Możesz go wykorzystać by zweryfikować tworzone przez siebie implementacje.

  1. calc.c: program proszący użytkownika o dwie liczby zmiennoprzecinkowe oraz operację matematyczną jaką ma na nich wykonać. Wspierane operacje to: dodawanie (+), mnożenie (*), potęga (^), logarytm (l). Wykorzystaj bibliotekę matematyczną oraz funkcję scanf.

  2. definitions.c: stwórz plik z programem, który zawiera poprawne definicje i inicjalizacje (wartościami różnymi od NULL jeśli nie powiedziano inaczej) zmiennych:

    1. tablica integers będąca wyzerowaną tablicą 256 liczb całkowitych

    2. tablica buffer będąca tablicą wyzerowanych 8 bitowych liczb bez znaku o rozmiarze 1MiB

    3. tablice multiplication będąca dwuwymiarową tablicą zawierającą wynik mnożenia indeksów danego pola

    4. zmienna constant będąca wskaźnikiem na stałą 16 bitową wartość całkowitoliczbową

  3. algorithm.h, algorithm.c: stwórz bibliotekę algorithm zawierającą:

    1. funkcję void swap_ints(int *a, int *b);, która zamieni miejscami a i b

    2. funkcję void swap(void *a, void *b, size_t size);, która zamieni miejscami a i b, gdzie a i b mają rozmiar size (tip: użyj funkcji memcpy)

Przykład do zadania 2: Zmienna foo będąca wskaźnikiem na stały wskaźnik 32 bitowej wartości zmiennoprzecinkowej tworzy następujący program:

float f1 = 3.14;
float *const f2 = &f1;
float *const *foo = &f2;

Typy

Typy podstawowe (zależne od platformy)

  • void, wykorzystywany głównie do oznaczenia wskaźników wskazujących na dowolny typ i funkcji, które nie zwracają wartości

  • char - typ reprezentujący „znak” (co jest prawdziwe wyłącznie dla znaków ASCII). W zależności od implementacji może być równoważny z signed char lub unsigned char

  • signed char, short, int, long, long long - typy całkowitoliczbowe (ze znakiem), których rozmiar jest zależny od platformy. Zdefiniowane są przez następującą nierówność: 1 <= sizeof(char) <= sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long). Jeśli chcemy wyrazić wprost ich całkowitoliczbowość można dodać prefix signed (np. signed int).

  • unsigned char, unsigned short, unsigned int, unsigned long, unsigned long long - odpowiedniki powyższych typów liczbowych, bez znaku.

  • float, double, long double - wartości zmiennoprzecinkowe, zgodne z standardem IEEE-754. float jest 32 bitową wartością pojedyńczej precyzji, double 64 bitową wartością pojedyńczej precyzji, a `long double zależy od platformy (co najmniej double)

  • bool (przed C23 w pliku <stdbool.h>), przechowujący wartości logiczne

  • operator sizeof(X), który zwraca wartość typu size_t (<stddef.h>)

  • wynik różnicy wskaźników (p - q, gdzie p, q są wskaźnikami) to wartość typu ptrdiff_t (<stddef.h>)

Tabela 1. Rozmiar (w bitach) w zależności od platformy
Typ Linux/macOS 32bit Windows 32bit Windows 64 bit Linux/macOS 64bit

char

8

8

8

8

short

16

16

16

16

int

32

32

32

32

long

32

32

32

64

long long

64

64

64

64

Wskaźnik

32

32

64

64

ptrdiff_t

32

32

64

64

size_t

32

32

64

64

Procesory Intela zazwyczaj wykorzystują wyłącznie 40 bitów do adresacji pamięci na procesorach 64 bitowych.

Różnica pomiędzy int, a bool

Zwyczajowo w języku C typ int traktowany jest jako główny typ do przechowywania wartości logicznych (prawdy i fałszu). Może to prowadzić jednak do nieintuicyjnych konsekwencji:

$ cat bool.c
#include <stdbool.h>
#include <stdio.h>

int main()
{
  printf("%d\n", (bool)0.5);
  printf("%d\n", (int)0.5);
  return 0;
}
$ cc bool.c -o bool
$ ./bool
1
0

Typy o znanym rozmiarze

Dostępne w pliku nagłówkowym <stdint.h>. Grupą najczęściej wykorzystywanych typów są: int8_t, int16_t, int32_t, int64_t, uint8_t, uint16_t, uint32_t, uint64_t i są, w praktyce, standardem.

Modyfikator const

const pozwala na deklarację zmiennej jako stałej - takiej, której wartości nie będziemy modyfikować. Główną siłą const jest informacja dla programisty - można przy pomocy const oznaczyć wskaźniki, które wyłącznie przekazują wartości do funkcji - a zatem gwarantujemy programiście, że nie będziemy ich modyfikować.

// const przed wskaźnikiem - to co jest stałe to `int` „pod” wskaźnikiem
void print_int(int const* x)
{
  printf("%d\n", *x);
  // *x += 1; <- to byłby błąd
  x += 1; // legalne, w tym przypadku bez zastosowania
}

// const za wskaźnikiem - to co jest stałe to wskaźnik, zmienną możemy modifkować
void modify(int* const x)
{
  // x += 1; <- to byłby błąd
  *x += 1;
  printf("%d\n", *x);
}
Kilka lat temu w środowisku języka C++ wybuchła rewolucja „east vs west const”.

Wskaźniki

Wskaźnik jest to adres do wartości określanego przez wskaźnik typu w pamięci. Wskaźniki są wytrychem pozwalającym na realizację wielu funkcjonalności bardziej złożonych języków programowania przy zachowaniu modelu operowania wyłącznie na wartościach liczbowych.

Przykładowe zastosowanie wskaźników:

#include <stdio.h>

int main()
{
  int x = 0;
  int *a, *b; // aby stworzyć zmiennę typu wskaźnikowego używamy *
  a = &x; // &x pobiera adres zmiennej x
  b = &x; // można mieć wiele wskaźników do tej samej zmiennej

  printf("a = %p\n", a); // %p używamy do wypisania wskaźników
  printf("b = %p\n", b);

  // *a używamy do dereferencji - zamiany adresu w wartość znajdującą się pod danym adresem
  printf("*a = %d\n", *a);
  printf("*b = %d\n", *b);
  printf("x  = %d\n", x);

  // możemy zmodyfikować zmienną przez którykolwiek z wskaźników
  *a += 1;
  *b += 1;
  // a także przez oryginalną nazwę
  x += 1;

  printf("*a = %d\n", *a);
  printf("*b = %d\n", *b);
  printf("x  = %d\n", x);

  return 0;
}

L-wartość i r-wartość (ang. l-value, r-value)

Wskaźniki zazwyczaj odwołują się do l-wartości: wartości, których czas życia (ang. lifetime) wykracza poza wyrażenie w którym są użyte. Zazwyczaj są to wartości, które są po lewej stronie znaku = - stąd l (ang. left) wartość.

W przeciwieństwie do nich są r-wartości, które mogą albo zostać użyte i zmienione w nową r-wartość, albo przypisane do zmiennej, stając się l-wartością.

  • Przykłady l-wartości: nazwa zmiennej int x; x, dereferencja (*x), wynik preinkrementacji (++x), indeksowanie x[i]

  • Przykłady r-wartości: literały liczbowe (5, 0.5), wartości zwracane przez funkcję add(5, 4), wynik operatora sizeof, pobranie adresu &x

Zastosowanie: przekazywanie wskaźników do funkcji

#include <stdio.h>

void increment(int *x)
{
  *x += 1;
  printf("in increment: %d\n", *x);
}

int main()
{
  int x = 0;

  printf("before increment: %d\n", x);
  increment(&x);
  printf("after increment: %d\n", x);
}
Wypróbuj powyższy przykład, a następnie zmodyfikuj go tak by nie przekazywać zmiennej x przez wskaźnik, a przez wartość. Jak to wpłynęło na zmianę wartości zmiennej x w funkcji main?

Arytmetyka

Wskaźniki są adresami, wskazującymi na zmienne określonego typu. Pozwala zdefiniować to arytmetykę wskaźników, która zachowuje własności oczekiwane od adresów do wartości danego typu.

Wskaźnik p przesunięty w prawo o 1 przez wyrażenie p+1 wskazuje na adres kolejnej wartości danego typu. Wyrażenie x+1 jest tym samym równoważne (char*)x + sizeof(*x). Podobnie można przesuwać wskaźnik w lewo, z wykorzystaniem operatora -.

Aby uzyskać wartość znajdującej się w oddalonej o i od x komórce pamięci można zastosować wyrażenie *(x+i). Jest ona równoważna operacji indeksu: x[i].

Definicja x[i] jako *(x+i) w języku C pozwala na przemienność operatora indeksu. Dowód: x[i] ≡ *(x+i) ≡ *(i+x) ≡ i[x].

Tablice statyczne

Tablice statyczne, są to tablice, których rozmiar jest zadeklarowany w programie - a przez to są alokowane przez kompilator w trakcie budowy pliku wykonywalnego.

Deklaracja tablicy

<typ> <nazwa-zmiennej>[<rozmiar>];

np. int nums[10];. Jeśli równocześnie inicjalizujemy tablicę, jej długość może być automatycznie wydedukowana z inicjalizacji:

// Wszystkie tablice xs mają te same długości i elementy
int xs1[5] = { 1, 2, 3, 0, 0 };
int xs2[]  = { 1, 2, 3, 0, 0 }; // pominięto rozmiar - jest automatycznie wypełniony przez kompilator
int xs3[5] = { 1, 2, 3 }; // pominięto 2 ostatnie elementy - automatycznie mają wartość zero

Zarządzanie rozmiarem tablicy w programie

Rozmiar tablicy możemy manualnie wykorzystywać w programie:

int xs[10];

for (int i = 0; i < 10; ++i) {
  xs[i] = i;
}

Nie jest to jednak wygodne i bezpieczne: po zmianie rozmiaru tablicy musimy manualnie zmodyfikować wszystkie miejsca użycia tablicy o nowy rozmiar.

Makro definiujące rozmiar tablicy

Pierwsze z dwóch popularnych rozwiązań: zdefiniowanie makra, które jest wykorzystywane jako rozmiar tablicy.

#define XS_LEN 10
int xs[XS_LEN];

for (int i = 0; i < XS_LEN; ++i) {
  xs[i] = i;
}

Makro określające rozmiar tablicy statycznej

Alternatywnym rozwiązaniem jest zdefiniowanie makra, które może pobrać rozmiar tablicy. Wykorzystywana jest mechanika operatora sizeof, który zwraca rozmiar całej tablicy. Dzieląc rozmiar całej tablicy przez rozmiar pojedyńczego elementu, otrzymujemy liczbę elementów!

#define ARRAY_LENGTH(X) (sizeof(X) / sizeof(X[0]))

int xs[10];

for (int i = 0; i < ARRAY_LENGTH(xs); ++i) {
  xs[i] = i;
}

Przekazywanie tablic do funkcji

C nie umożliwia przekazywanie (ani zwracanie) tablic statycznych do funkcji. Wymagane jest przekazywanie ich przez wskaźnik. Ponieważ wskaźnik nie zawiera informacji o długości tablicy, zazwyczaj przekazywany jest również jej rozmiar:

#include <stdio.h>

void print(int *xs, int n)
{
  for (int i = 0; i < n; ++i) {
    if (i == 0) {
      printf("%d", xs[i]);
    } else {
      printf(", %d", xs[i]);
    }
  }
  printf("\n");
}

int main()
{
  int numbers[] = { 1, 2, 3, 4, 5, 6 };
  print(numbers, 6);
  return 0;
}

Alternatywnym sposobem przekazywania tablic jest para wskaźników, jeden wskazujący na początek, drugi na koniec tablicy. Zwyczajowo przekazywany jest wskaźnik do elementu za ostatnim elementem tablicy:

#include <stdio.h>

void print(int *begin, int *end)
{
  for (int *it = begin; it != end; ++it) {
    if (it == begin) {
      printf("%d", *it);
    } else {
      printf(", %d", *it);
    }
  }
  printf("\n");
}

/*
| 1 | 2 | 3 | 4 | 5 | 6 |
0   1   2   3   4   5   6
^ begin                 ^ end (begin + 6)
^ numbers
*/

int main()
{
  int numbers[] = { 1, 2, 3, 4, 5, 6 };
  print(numbers, numbers + 6);
  return 0;
}
Model 2 jest podstawą dla modelu iteratorów języka C++.

Funkcja scanf

Funkcja scanf pozwala na pobieranie wartości z standardowego wejścia. Wykorzystuje te same oznaczenia co printf.

#include <stdio.h>

int main()
{
  int a, b;
  double c;

  scanf("%d", &a); // wczytaj liczbę całokwitą
  scanf("%f", &c); // wczytaj liczbę zmiennoprzecinkową
  scanf("%d %d", &a, &b); // wczytaj dwie liczby całkowite rozdzielone spacją

  printf("a=%d, b=%d, c=%f\n", a, b, c);
  return 0;
}

Przykładowy program, który wczytuje liczby rozdzielone przecinkami zaprezentowany został poniżej. Wykorzystuje on wartość zwracaną przez scanf - liczbę wczytanych elementów:

#include <stdio.h>

int main()
{
  int total = 0;

  int n;

  scanf("%d", &n);
  total += n;

  while (scanf(",%d", &n) == 1) {
    total += n;
  }

  printf("Total: %d\n", total);

  return 0;
}