Zadania

Dla każdego z punktów przygotuj program w języku C, realizujący daną funkcjonalność:

  1. truth-machine.c: jeśli użytkownik podał 0, wypisz 0 i zakończ program. Jeśli użytkownik podał 1, wypisuj 1 w nieskończoność.

  2. wc.c:

    1. Zliczanie linii: przyjmuj znak po znaku od użytkownika. W momencie napotkania końcu pliku wypisz liczbę podanych przez użytkownika linii.

    2. Rozszerz program zliczania linii o zliczanie znaków.

  3. Znajdź funkcję, która występowała w standardzie C, ale została z niego usunięta

  4. getchar.c: Poniższy program prezentujący wykorzystanie funkcji getchar można zapisać krócej - wykorzystaj pętle for oraz zredukuj liczbę miejsc w których funkcja getchar jest wywoływana do jednego.

#include <stdio.h>

int main()
{
	int c = getchar();
	while (c != EOF) {
		printf("%c", c);
		c = getchar();
	}
	return 0;
}

Kod źródłowy należy umieścić w repozytorium GIT.

Minimalny przykład

Stwórz plik tekstowy 01_min.c o następującej zawartości:

/* Poniższy program wypisuje ciąg znaków „hello world” */
#include <stdio.h>

int main()
{
	printf("hello, world\n");
	return 0;
}

Następnie skompiluj z wykorzystaniem programu cc. W terminalu (Ctrl+` w Visual Studio Code) uruchom następujące polecenie:

cc -o 01_min 01_min.c

A następnie uruchom program:

./01_min

Omówienie

Plik 01_min.c jest plikiem źródłowym (ang. source file) zawierającym kod w języku C. Rozszerzeniem plików źródłowych języka C jest .c.

Plik rozpoczyna się od komentarza, który dokumentuje zawartość programu. Język nie wymaga dokumentowania kodu - jest to dobra praktyka. Zastosowano komentarz blokowy (ang. block comment). Komentarz blokowy rozpoczyna się ciągiem znaków /* i kończy */. Komentarze blokowe nie mogą być zagnieżdżone:

/* wewnątrz komentarza /* nadal wewnątrz */ poza */ poza

Następnie dołączany jest plik nagłówkowy (ang. header file) będący częścią biblioteki standardowej języka C. stdio.h zawiera deklaracje funkcji realizujących operacje wejścia/wyjścia, w tym użytej poniżej printf.

Kolejno deklarowana i definiowana jest funkcja main. Funkcja main jest funkcją, która jest uruchamiana w momencie uruchomienia programu. Posiada ona kilka możliwych sposobów definicji, w zależności od przyjmowanych przez program argumentów. W aktualnej formie argumenty przekazane do programu są ignorowane. Deklaracja funkcji składa się z następujących komponentów:

typ-zwracany nazwa-funkcji(argument1, argument2, ...);

Funkcja int main() nie wymaga argumentów i zwraca wartość całkowitą (int - integer). Oznacza to, że przed opuszczeniem funkcji musi zostać zwrócona wartość - stąd kończące jej ciało return 0;. Zgodnie z konwencją systemów Unixowych, wartość 0 oznacza pomyślne zakończenie wykonywania programu (wartości różne od 0 oznaczają zakończenie programu błędem). Funkcje, które nie zwracają wartości mają określony typ zwracany jako void.

W ciele funkcji, zawartym pomiędzy nawiasami klamrowymi ({ i }), wywoływana jest funkcja printf (man, cppreference). Pierwszym argumentem funkcji jest łańcuch znaków określający tzw. format - specyfikację co funkcja ma wypisać i jakich kolejnych argumentów może wykorzystywać. Funkcja wypisuje podany tekst na standardowe wyjście, będące w tym przypadku ekranem terminala w edytorze VS Code.

W łańcuchu znaków "hello, world\n" wykorzystano specjalny symbol \n oznaczający znak nowej linii. Funkcja printf wypisuje wyłącznie przekazaną do niej treść.

Kompilator cc

cc jest domyślnym kompilatorem języka C w systemach opartych o standard POSIX (m.in. dystrybucje Linuxa, macOS). Podstawowym parametrem jest przekazywana nazwa pliku źródłowego, zawierający kod w języku C. Poprzez parametr -o można wybrać nazwę wyjściowego pliku wykonywalnego - domyślną jest a.out.

Aby sprawdzić jaki kompilator jest kompilatorem domyślnym można użyć polecenia which i ls:

$ ls -l $(which cc)
lrwxrwxrwx 1 root root 3 lut  8 11:41 /usr/bin/cc -> gcc

Język C

Wykorzystanie

Standaryzacja

Język C jest aktywnie rozwijanym standardem ISO. Rozwijany jest przez międzynarodową komisję JTC1/SC22/WG14. Do publicznego dostępu dostępne są szkice standardów, a proces pracy komisji (w tym propozycje rozszerzania i zmieniana języka) są w większości jawne.

Poniżej opisano kolejno wydane standardy języka C wraz z głównymi funkcjonalnościami:

  • „The C Programming Language”, Brian Kernighan, Dennis Ritchie (1978) - pierwsza nieformalna specyfikacja języka C stworzona przez oryginalnych projektantów i programistów języka C (oraz systemu Unix)

  • Standard C89 (1989) - pierwsza formalna specyfikacja języka C, stworzona przez American National Standard Institute (ANSI)

  • Standard C99 (1989) - rozszerzenie standardu C89 przez ISO, m.in. pozwalające na mieszanie instrukcji i deklaracji wewnątrz funkcji, komentarze liniowe //, literały struktur

  • Standard C11 (1989) - wyrażenia generyczne oraz wielowątkowość (jako element standardu)

  • Standard C17 (1989) - poprawki do standardu C11, bez nowych funkcjonalności

  • Standard C23 (1989) - dołączanie plików binarnych przez #embed i funkcje biblioteczne wykonujące operacje bitowe

Kompilatory języka C oferują możliwość wyboru standardu wg którego program zostanie skompilowany. Najpowszechniejszą opcją jest parametr -std=<wersja>, gdzie wersja to m.in. wymienione powyżej standardy (zapisywane z użyciem małego c). Kompilatory mogą oferować dodatkowe wersje, w tym wsparcie planowanych przyszłych wersji (np. c2y jako oznaczenie kolejnej wersji języka C), wersji specyficznych dla danego kompilatora (np. gnu11 jako oznaczenie wersji zdefiniowanej przez kompilator GNU C - gcc). Rekomendowane jest używanie wyłącznie wersji języka C określonych przez standard ISO w celu zapewniania kompatybilności kodu źródłowego pomiędzy różnymi kompilatorami i systemami operacyjnymi.

Bezpieczeństwo

Szerokie zastosowanie komputerów, w tym w ramach infrastruktury krytycznej prowadzi do konieczności rozpatrywania języków programowania w kontekście cyberbezpieczeństwa. Bezpieczeństwo języka programowania można zdefiniować przez kilka wskaźników:

  1. możliwość modelowania wymagań zewnętrznych (wymagania biznesowe) wewnątrz danego języka

  2. posiadanie mechanizmów ochrony przed typowymi błędami osób programujących w danym języku/paradygmacie

  3. możliwość statycznej analizy kodu źródłowego

  4. możliwość formalnej analizy kodu źródłowego (w tym formalny dowód poprawności programu)

  5. łatwość manualnej analizy kodu źródłowego

Założenia projektowe języka C - prostota, uniwersalność, prosty model kompilacji - utrudniają realizację niektórych z powyższych założeń. Przykładem może być prostota języka ułatwia manualną analizę kodu źródłowego, ale utrudnia formalną weryfikację czy możliwość modelowania wymagań biznesowych.

Rekomendowane jest wykorzystywanie dodatkowych narzędzi weryfikujących poprawność tworzonych programów, jak i wykorzystywanie opcji kompilatora pozwalających na wykrywanie niebezpiecznych zachowań programu.

Podstawy języka C

Funkcja printf

Funkcja printf przyjmuje tzw. format, będący specyfikacją tego co ma być wypisane. Sekwencja znaków rozpoczynająca się od % ma specjalne znaczenie:

  • %% oznacza wypisanie znaku %

  • %d oznacza wypisanie kolejnego argumentu całkowitoliczbowego

  • %c oznacza wypisanie wartości liczbowej jako znaku ASCII

  • oraz inne. Pełna lista dostępna jest w ramach dokumentacji funkcji printf: (man, cppreference). Kolejne będą wprowadzane w miarę potrzeb.

Przykładowy program, pokazujący zastosowanie podstawowego formatu w funkcji printf:

#include <stdio.h>

int getRandomNumber()
{
	return 4; /* chosen by fair dice roll
	             guaranteed to be random */
}

int main()
{
	printf("printing some");
	printf(" text\n");
	printf("%d + %d = %d\n", 1, 2, 3);
	printf("Random number for today is %d\n", getRandomNumber());
}

Funkcja getchar

int getchar(void);

Funkcja getchar zwraca znaki podane przed użytkownika na standardowe wejście (okno terminala) lub EOF w momencie osiągnięcia końca pliku (lub błędu). Przykład wykorzystania:

#include <stdio.h>

int main()
{
	int c = getchar();
	while (c != EOF) {
		printf("%c", c);
		c = getchar();
	}
	return 0;
}

Zmienne

Język C wymaga uprzedniej deklaracji każdej wykorzystywanej nazwy przed jej wykorzystaniem (w tym przed przypisaniem wartości). Deklaracja zmiennej wygląda następująco:

typ-zmiennej nazwa1, nazwa2, ...;

Przykładowo chcąc mieć trzy zmienne całkowite:

int result, x, n;

lub równoważnie:

int result;
int x;
int n;

Do zmiennych można przypisywać wartość. Pierwsze przypisanie nazywane jest inicjalizacją.

#include <stdio.h>

int main()
{
	int n = 33;
	printf("Initialized n: %d\n", n);

	n = 44;

	printf("Assigned to n: %d\n", n);

	return 0;
}

Zmienne zdefiniowane wewnątrz ciała funkcji to zmienne lokalne. Zmienne zdefiniowane poza funkcjami to zmienne globalne - każda z funkcji ma do nich dostęp.

int global_variable;

void function1()
{
	int local_variable_a = 4;
}

void function2()
{
	global_variable = 3;
	local_variable_a = 100;
}

int main()
{
	return 0;
}

Kompilując powyższy program otrzymujemy następujący błąd (komunikat może różnić się od systemu):

$ cc -o 01_globals_locals 01_globals_locals.c
01_globals_locals.c: In function ‘function2’:
01_globals_locals.c:11:9: error: ‘local_variable_a’ undeclared (first use in this function); did you mean ‘global_variable’?
   11 |         local_variable_a = 100;
      |         ^~~~~~~~~~~~~~~~
      |         global_variable
01_globals_locals.c:11:9: note: each undeclared identifier is reported only once for each function it appears in

Deklaracje funkcji

Funkcje w języku C składają się z deklaracji i definicji. Deklaracja funkcji jest wprowadzeniem nazwy funkcji do programu, określeniem zwracanego przez nią typu oraz jej prototypu:, liczby argumentów oraz typów argumentów.

Deklaracja funkcji wygląda następująco:

typ-zwracany nazwa-funkcji(argument1, argument2, ...);

W przypadku braku argumentów:

  • przed C23: funkcja nie ma prototypu - nie jest sprawdzana poprawność przekazywanych argumentów (można podać dowolną liczbę argumentów w tym zero)

  • od C23: funkcja nie przyjmuje argumentów.

Funkcja nie przyjmująca argumentów może być również określona przez użycie słowa kluczowego void w miejscu listy argumentów.

Przykłady deklaracji funkcji:

void returns_nothing_accepts_anything_before_c23();
void returns_nothing_accepts_nothing();

/* Deklaracje funkcji mogą się powtarzać, o ile posiadają ten sam typ zwracany i prototyp */
void returns_nothing_accepts_nothing();

int returns_a_number_and_accepts_two_numbers(int a, int b);

Definicje funkcji

Definicja funkcji przypisuje zachowanie (ciało funkcji) do deklaracji funkcji. Przykład:

#include <stdio.h>

int add(int a, int b)
{
	return a + b;
}

int main()
{
	printf("%d\n", add(42, 43));
}

Funkcje w języku C muszą być zadeklarowane przed ich użyciem w innych funkcjach. Jeśli w funkcji main chcielibyśmy wykorzystać funkcję add, ale zdefiniować ją później musimy poprzedzić funkcję main deklaracją funkcji add.

#include <stdio.h>

int add(int a, int b);

int main()
{
	printf("%d\n", add(42, 43));
}

int add(int a, int b)
{
	return a + b;
}

Niektóre style programowania w języku C zalecają deklarację wszystkich funkcji na początku programu.

Operatory i wartości zmiennoprzecinkowe

Język C posiada podstawowy zestaw operacji arytmetycznych:

  • Dodawanie +, odejmowanie -

  • Mnożenie *, dzielenie / i reszta z dzielenia % (modulo)

Symbole odpowiadające operacją arytmetycznym to operatory.

Jeśli dwie wartości są całkowitoliczbowe (int), wynik operacji jest również całkowitoliczbowy. Jeśli wewnątrz operacji co najmniej jedna z wartości jest zmiennoprzecinkowa (double) wynik jest zmiennoprzecinkowy.

Liczbę zmiennoprzecinkową można otrzymać przez wykorzystanie literału zmiennoprzecinkowego (np. 3.14, 1.0) lub przez przypisanie do zmiennej zmiennoprzecinkowej. Liczby zmiennoprzecinkowe wypisywane są przez format %f.

#include <stdio.h>

int main()
{
	double pi = 3.14;
	int circle_radius = 20;

	printf("Circle radius is: %d\n", 20);
	printf("Circle perimeter is: %f\n", 2 * pi * circle_radius);
	printf("Circle area is: %f\n", pi * circle_radius * circle_radius);
	return 0;
}

Jeśli chcemy równocześnie dodać i przypisać nowy wynik do zmiennej można użyć formy skróconej:

int a = 0;
a = a + 1;
a += 1; /* równoważne z powyższym */

Każdy operator arytmetyczny może być użyty w formie skróconej.

Jeśli interesuje nas tylko wartość całkowita z wartości zmiennoprzecinkowej możemy przypisać ją do zmiennej całkowitej:

int a = 3.14;
printf("%d\n", a); /* wypisze 3 */

Powyższy kod można skrócić, korzystając z rzutowania - konwersji typu na inny typ. Rzutowanie wykonuje się przez poprzedzenie wyrażenia typem docelowym w nawiasie.

printf("%d\n", (int)3.14); /* wypisze 3 */

Język C dodatkowo wspiera operację inkrementacji (zmienna` lub `zmienna) i dekrementacji (--zmienna lub zmienna--) - odpowiednio zwiększenia lub zmniejszenia wartości o 1. W zależności od tego czy operator jest przed czy po zmiennej mowa o pre- i post- inkrementacji/dekrementacji. Pre i post inkrementacja/dekrementacja różnią się zwracaną przez siebie wartością.

#include <stdio.h>

int main()
{
	int a = 0;
	printf("Pre-increment before: %d\n", a);
	printf("Pre-increment returns: %d\n", ++a);
	printf("Pre-increment after: %d\n", a);

	a = 0;
	printf("Post-increment before: %d\n", a);
	printf("Post-increment returns: %d\n", a++);
	printf("Post-increment after: %d\n", a);
}

Operatory porównania i logiczne

W C wartością oznaczającą fałsz jest 0, natomiast wartością oznaczającą prawdę jest każda wartość różna od 0. Domyślnie operacje zwracające wynik logiczny (= liczbę całkowitoliczbową) zwracają 1 jako wartość prawdziwą.

#include <stdio.h>

int main()
{
	printf("3 < 4 is: %d\n", 3 < 4);
	printf("3 > 4 is: %d\n", 3 > 4);
	printf("3 <= 4 is: %d\n", 3 <= 4);
	printf("3 >= 4 is: %d\n", 3 >= 4);
	printf("3 == 4 is: %d\n", 3 == 4);
	printf("3 != 4 is: %d\n", 3 != 4);

	printf("0 && 0 is: %d\n", 0 && 0);
	printf("0 && 1 is: %d\n", 0 && 1);
	printf("1 && 0 is: %d\n", 1 && 0);
	printf("1 && 1 is: %d\n", 1 && 1);

	printf("0 || 0 is: %d\n", 0 || 0);
	printf("0 || 1 is: %d\n", 0 || 1);
	printf("1 || 0 is: %d\n", 1 || 0);
	printf("1 || 1 is: %d\n", 1 || 1);

	return 0;
}
W standardzie C99 wprowadzono plik nagłówkowy stdbool.h z definicjami bool jako typ logiczny, true jako 1, false jako 0. C23 zdeprecjonowało ten mechanizm, wprowadzając prawdziwe stałe true i false do języka C.

Operatory porównania zapewniają własność minimalnej ewaluacji - jeśli w momencie wykonywania operacji logicznych wartość wyrażenia jest już znana, pozostałe operacje są pomijane:

#include <stdio.h>

int main()
{
	0 && printf("not printed\n");
	1 && printf("printed\n");

	0 || printf("printed\n");
	1 || printf("not printed\n");
}

Deklaracje, wyrażenia i instrukcje

Język C rozróżnia wyrażenia, deklaracje, definicje i instrukcje. Program w języku C składa się z deklaracji i definicji. Ciało funkcji kłada się z instrukcji i deklaracji, które mogą zawierać wiele wyrażeń.

Instrukcje wykonywane są sekwencyjnie. Instrukcje muszą być kończone znakiem średnika ;, z wyjątkiem instrukcji złożonej.

printf("hello, world\n"); to instrukcja składająca się z wyrażenia wywołania funkcji. W ramach wywołania funkcji jest wyrażenie nazwy funkcji printf, które wraz z nawiasami tworzy wyrażenie wywołania. Wewnątrz wywołania znajduje się wyrażenie literału ciągu znaków "hello world\n".

Instrukcje języka C to:

  • instrukcje złożone (ang. compound statement), będące zbiorem instrukcji w bloku stworzonym z nawiasów klamrowych ({}):

int c;
{
  int a;
  {
		int b;
		a = b + c;
	}
}
  • instrukcja wyboru - instrukcja wybierająca jedną z instrukcji na podstawie wyrażenia. Dostępne są 3:

    • if (wyrażenie) instrukcja

    • if (wyrażenie) instrukcja else instrukcja

    • switch (wyrażenie) instrukcja

  • instrukcje iteracji - instrukcje, które powtarzają wykonanie instrukcji na podstawie wyrażenia. Dostępne są 3:

    • while (wyrażenie) instrukcja

    • do wyrażenie while (instrukcja);

    • for (inicjalizacja; opcjonalne wyrażenie; opcjonalne wyrażenie) instrukcja, gdzie inicjalizacja może być deklaracją lub wyrażeniem

  • instrukcje skoku - instrukcje, które umożliwiają wpływ na wykonywanie programu:

    • break;

    • continue;

    • return opcjonalne wyrażenie;

    • goto etykieta;

  • instrukcja wyrażenia (ang. expression statement) - instrukcja zawierająca wyrażenie

Instrukcje mogą być etykietowane:

  • jako cel instrukcji goto przez nazwa:

  • jako cel instrukcji switch case wartość:

  • jako cel instrukcji switch default:

Instrukcja warunkowa if

Instrukcja warunkowa if wykonuje zawartą w niej instrukcję wyłącznie wtedy kiedy wyrażenie jest prawdziwe (różne od 0). Jeśli posiada część else to instrukcja po słowie kluczowym else jest wykonywana kiedy wyrażenie jest fałszywe.

#include <stdio.h>

int main()
{
	if (0) printf("not printed\n");
	if (1) printf("printed\n");

	if (0) printf("not printed\n"); else printf("printed\n");
	if (1) printf("printed\n"); else printf("not printed\n");
}

Język C w większości przypadków pomija białe znaki (wyjątkiem jest preprocesor omówiony na następnych zajęciach). Poniższy kod zachowa się tak samo jak powyższy.

#include <stdio.h>

int main()
{
	if (0)


	         printf("not printed\n");
	if (1)
		printf("printed\n");

	if (0) printf("not printed\n");
	else printf("printed\n");

	if (1)
		printf("printed\n");
	else
		printf("not printed\n");
}

Instrukcja wyboru switch

Instrukcja switch przeskakuje do tej etykiety case, której wartość odpowiada wartości wyrażenia. Jeśli żadna z etykiet nie została dopasowana, wybierana jest etykieta domyślna - default. Należy zwrócić uwagę, że switch oferuje wyłącznie przeskok - wykonywane są wszystkie kolejne wyrażenia od danej etykiety. Umożliwia to grupowanie etykiet (jak w poniższym przykładzie). Jeśli chcemy zakończyć wykonywanie instrukcji switch należy użyć instrukcji break.

#include <stdio.h>

int main()
{
	int n = 4;

	switch (n) {
	case 2:
	case 3:
	case 5:
	case 7:
		/* listing all other primes left as an excercise to the reader */
		printf("prime\n");
		break;

	default:
		printf("not a prime\n");
	}

	return 0;
}

Instrukcja pętli warunkowej while i do..while

Pętla while powtarza wykonanie podążających za nią instrukcji wtedy i tylko wtedy kiedy warunek jest prawdziwy (różny od 0).

#include <stdio.h>

int main()
{
	while (1) printf("y\n");
	return 0;
}
Powyższy program jest niemal pełną implementacją programu yes.

Przykładowy program, który wypisuje liczby od 1 do 10:

#include <stdio.h>

int main()
{
	int i = 1;
	while (i <= 10) {
		printf("%d\n", i);
		++i;
	}
	return 0;
}

Pętla do..while najpierw wykonuje ciało pętli, a następnie sprawdza warunek:

#include <stdio.h>

int main()
{
	int i = 1;
	do {
		printf("%d\n", i);
		++i;
	} while (i <= 10);
	return 0;
}

Powyższy program możemy zwęzić:

#include <stdio.h>

int main()
{
	int i = 1;
	do printf("%d\n", i); while (++i <= 10);
	return 0;
}

Instrukcje skoku break i continue

Instrukcje skoku break i continue mogą być wykorzystywane wewnątrz pętli w celu zarządzania dalszym wykonaniem programu. Instrukcja break przerywa wykonanie pętli, natomiast instrukcja continue pomija pozostałe instrukcje wewnątrz pętli i powraca do warunku.

#include <stdio.h>

int main()
{
	int i = 0;
	while (i <= 10) {
		++i;
		if (i < 5) continue;
		printf("%d\n", i);
	}

	i = 0;
	while (i <= 10) {
		++i;
		if (i > 5) break;
		printf("%d\n", i);
	}
	return 0;
}

Instrukcja pętli for

Iteracja po zakresie liczb (lub ogólnie w formie początek, warunek, następny element) jest bezpośrednio wspierana przez pętlę for:

#include <stdio.h>

int main()
{
	for (int i = 1; i <= 10; ++i)
		printf("%d\n", i);
	return 0;
}

Instrukcja skoku goto

Instrukcja skoku widziana jest jako szkodliwy element języka programowania. Jest to jednak użyteczny element języka C, umożliwiający niektóre wzorce trudne lub niemożliwe do osiągnięcia przy użyciu innych metod. Jeśli jednak możliwe jest zastosowanie innych instrukcji lub wzorców, użycie instrukcji skoku goto jest niewskazane.

Przykład nieuzasadnionego użycia goto - samodzielna implementacja pętli:

#include <stdio.h>

int main()
{
	int i = 0;

	next: if (i <= 10) {
		printf("%d\n", i);
		++i;
		goto next;
	}
}

Przykład uzasadnionego użycia goto - powrót do warunku pętli zewnętrznej:

#include <stdio.h>

int main()
{
	outer: for (int i = 1; i < 10; ++i) {
		for (int j = 0; j < 10; ++j) {
			if (i * j > 25) goto outer;
		}
		printf("%d\n", i);
	}
}