Zawartość artykułu
Na moment pisania artykułu języki
C i
C++, wciąż są jednymi z najpopularniejszych języków programowania ogólnego zastosowania, ostatnio według
indeksu TIOBE są na podium najpopularniejszych języków programowania, będąc na pozycjach II i III.
C++ wywodzi się z
C, jednakże są to oddzielne języki programowania, które zachowują dużą kompatybilność, ale pozostają odrębnymi językami.
W tym artykule porównamy te języki programowania, ma to służyć następującym celom:
Artykuł wymaga znajomości C lub C++ na poziomie umożliwiającym pisanie programów. Zalecana jest znajomość aktualnych standardów C23 i C++23 (artykuł pisany w 2025 roku).
Struktura artykułu
W niniejszym artykule nie wymieniłem wszystkich różnic między C++23 a C23 - jedynie te co uznałem za istotne, często stosowane. Jak potrzebujesz więcej to zacznij od tego artykułu. Następnie zachęcam do zapoznania się z linkami wyjaśniającymi pewne zagadnienia. A poza tym polecam książki do programowania - nic tak dogłębnie nie tłumaczy jak dobrze napisana książka.
Życiorys C i C++
Język
C został stworzony w 1972 roku przez
Dennisa Ritchie w
Bell Labs przy udziale Alana Snydera (pracował nad
preprocesorem). Z kolei
C++ powstał jako rozszerzenie języka
C, początkowo określane jako "C z klasami" (ang. "C with classes"), inspirowane obiektowością
języka Simula, pierwszego języka obiektowego. Twórcą języka programowania
C++ jest
Bjarne Stroustrup. Początkowo
C++ był tłumaczony na
C za pomocą kompilatora
Cfront, ale obecnie dominują kompilatory generujące bezpośrednio kod maszynowy.
C++ stara się zachować możliwie dużą zgodność z
C, choć nie zawsze jest to możliwe.
C++ to inny język programowania niż C. Niestety, niektórzy wciąż określają
C++ jako "C z klasami".
Rozwój języków
Rozwój
C i
C++ przebiega niezależnie, choć komitety standaryzacyjne współpracują lub inspirują się nawzajem.
Standardy języka C
Standardy
C powstają mniej regularnie i wprowadzają mniej drastyczne zmiany. Niektóre elementy m.in.
const
, możliwość definiowania zmiennych w dowolnym miejscu bloku,
inline
w
C99, czy typ
bool
{
true
,
false
} w
C23, pochodzą z
C++.
Standardy języka C++
Standardy
C++ pojawiają się regularnie co 3 lata, rozszerzając bibliotekę standardową i składnię, zachowując wysoką kompatybilność wsteczną. Usuwanie czegoś ze standardu odbywa się bardzo powoli (jest rozłożone na kilka kolejnych standardów), przykładowo
std::auto_ptr
oznaczono jako przestarzałe w
C++11, a usunięto w
C++17.
Porównanie języków C i C++
Mimo że
C i
C++ to oddzielne języki, Bjarne Stroustrup w publikacji
C and C++: a Case for Compatibility zachęcał do dążenia do kompatybilności między nimi, ale nie za wszelką cenę.
Witaj świecie
Każdy z języków programowania ma swoje "witaj świecie".
Różnice składniowe przy funkcjonalnościach dostępnych w obydwu językach
Poniżej przedstawiono kluczowe różnice składniowe między językami
C++23 a
C23, z przykładami kodu ilustrującymi te różnice. Celem jest pokazanie, jak
C++ wykracza poza
C, zachęcając programistów do korzystania z pełnych możliwości
C++ zamiast pisania w stylu "C+".
Przeciążanie nazwy funkcji
W
C++ możliwe jest przeciążanie funkcji, czyli definiowanie wielu funkcji o tej samej nazwie, ale z różnymi parametrami. W
C każda funkcja musi mieć unikalną nazwę, a przeciążanie nie jest wspierane, również w
C23.
Przeciążanie nazwy funkcji ma swoje konsekwencje w kodzie skompilowanym. Nazwy funkcji w
C, jako, że mają być unikalne po skompilowaniu nazywają się tak samo jak w kodzie źródłowym.
void func( int a ) { }
void func2( double d ) { }
void func3( int i, double d, float f ) { }
int main() { }
Powyższy kod był skompilowany na Linuxie przy użyciu
gcc
, symbole w pliku skompilowanym można podejrzeć przy użyciu
narzędzia nm
, rezultat takiego wywołania jest podobny do:
nm --print-size a.out | grep ' T ' # T - oznacza funkcje
0000000000001150 T _fini
0000000000001119 000000000000000a T func
0000000000001123 000000000000000c T func2
000000000000112f 0000000000000014 T func3
0000000000001000 T _init
0000000000001143 000000000000000b T main
0000000000001020 0000000000000026 T _start
Dla
C++ występuje manglowanie nazw, czyli doklejenie do nich typów, co widać po skompilowaniu tego samego kodu na kompilatorze
g++
:
nm --print-size a.out | grep ' T '
0000000000001150 T _fini
0000000000001000 T _init
0000000000001143 000000000000000b T main
0000000000001020 0000000000000026 T _start
0000000000001119 000000000000000a T _Z4funci
0000000000001123 000000000000000c T _Z5func2d
000000000000112f 0000000000000014 T _Z5func3idf
Na szczęście mamy możliwość odmanglowania nazw:
nm --demangle --line-numbers --print-size a.out | grep ' T '
0000000000001150 T _fini
0000000000001000 T _init
0000000000001143 000000000000000b T main
0000000000001020 0000000000000026 T _start
0000000000001119 000000000000000a T func(int)
0000000000001123 000000000000000c T func2(double)
000000000000112f 0000000000000014 T func3(int, double, float)
Nie mając opcji
--demangle
możemy użyć narzędzia
c++filt
otrzymując podobny rezultat wołając:
nm --line-numbers --print-size a.out | c++filt | grep ' T '
Właśnie dzięki niemanglowaniu nazw funkcji w
C funkcje jak się nazywają w kodzie tak się nazywają też po skompilowaniu i tak łatwo jest pisać biblioteki w
C dla różnych języków programowania. W
C++ mamy większą wygodę, ale trzeba dodatkowo wyłączyć manglowanie dla funkcji wystawianych publicznie z biblioteki.
W
C, od wersji
C11 mamy tzw.
generyczną selekcję typów, która zachowuje się podobnie jak przeciążanie, z tą różnicą, że korzystamy z preprocesora.
Domyślne argumenty funkcji w C++
W
C++ można definiować funkcje z domyślnymi argumentami, czyli takimi, które mają przypisaną wartość, gdy użytkownik ich nie poda. Pozwala to zredukować liczbę przeciążeń oraz poprawić czytelność kodu.
W
C23 domyślne argumenty funkcji nie są wspierane – trzeba ręcznie przeciążać funkcje (np. przez makra lub różne nazwy) lub jawnie przekazywać wszystkie argumenty.
#include <print>
void greet( const char * name = "Gościu" )
{
std::println( "Witaj, {}!", name );
}
int main()
{
greet( "Ala" );
greet(); }
Automatyczna dedukcja typów
Automatyczna dedukcja typów pozwala kompilatorowi wywnioskować typ zmiennej lub wyrażenia na podstawie inicjalizatora, co upraszcza kod i oraz propaguje zmiany w różne miejsca w kodzie (zmieniamy raz i się propaguje w inne miejsca zależne od tego). W
C++23 (zaczynając od
C++11) i
C23 (we wcześniejszych wersjach tego nie było) realizowana jest przez słowo kluczowe
auto
, choć z istotnymi różnicami w zastosowaniu.
W
C++23 auto
umożliwia dedukcję typów zmiennych, wartości zwracanych przez funkcje oraz parametrów w szablonach (o szablonach później). Funkcja ta, wprowadzona w
C++11, została rozszerzona w kolejnych standardach, np. w
C++14 dla typów zwracanych przez funkcje.
W
C23 auto
zostało wprowadzone do inferencji typów, ale tylko dla definicji obiektów (zmiennych). Nie można go używać dla typów zwracanych przez funkcje ani dla parametrów funkcji, co ogranicza jego zastosowanie w porównaniu do
C++.
Co więcej, w
C23 auto
zachowuje swoje tradycyjne znaczenie jako specyfikator klasy przechowywania, gdy jest używane z typem, np.:
Jest to jednak redundantne, ponieważ
int z = 10;
daje ten sam efekt, jako że
auto
jest domyślnym trybem przechowywania dla zmiennych lokalnych.
W
C++ auto
nie pełni już roli specyfikatora klasy przechowywania – jest przeznaczone wyłącznie do dedukcji typów, co czyni je bardziej spójnym w kontekście nowoczesnego programowania.
Structured binding w C++
W
C++17 przy pomocy słówka kluczowego
auto
wprowadzono mechanizm
structured binding, który pozwala na automatyczną dedukcję typów dla składowych struktur, krotek lub tablic, umożliwiając ich rozpakowanie do oddzielnych zmiennych. Upraszcza to dostęp do elementów złożonych typów bez konieczności jawnego odwoływania się do ich pól. W
C23 nie ma odpowiednika tego mechanizmu, co wymaga ręcznego dostępu do pól struktur.
Podsumowanie: Oba języki oferują automatyczną dedukcję typów za pomocą
auto
, ale
C++23 jest znacznie bardziej wszechstronny, wspierając dedukcję w szerszym zakresie kontekstów, takich jak funkcje i szablony. W
C23 dedukcja jest ograniczona do zmiennych, a auto zachowuje swoje starsze znaczenie jako specyfikator klasy przechowywania.
Referencje
Język C do wersji C23 włącznie nie posiada referencji. Muszą nam wystarczyć wskaźniki.
W
C++ istnieją referencje, będące aliasami dla zmiennych/stałych. Przykład tzw. lewych-referencji (l-referencja, ang. l-reference) w
C++:
int a = 5;
int & ref = a;
ref = 6;
std::cout << a << " vs " << ref << std::endl;
Poza l-referencjami istnieją również prawe-referencje (ang. r-reference), które mogą wskazywać na wartości chwilowe (tzw. prawe wartości) :
int f() { return 4; }
int main()
{
int && rref = f();
int i = 5;
int && rref2 = std::move( i ); }
Mamy również dostępną uniwersalną referencję, która może wskazać na wszystko (również wartości chwilowe):
int f() { return 4; }
int main()
{
auto && rref = f();
int i = 5;
auto && rref2 = i;
}
Stałe
Do oznaczania stałych służy słówko kluczowe
const
w obydwu językach. W
C jest to bardziej na etapie deklaracji, czyli można przez rzutowanie wskaźnika zmienić wartość rzekomej stałej. W
C++ stałe są niezmienne, a próba ich zmiany przez wskaźnik jest niezdefiniowana (ang. undefined behaviour) i może doprowadzić do wywalenia programu. W
C++ const
ma szersze zastosowanie, gdyż poza stałymi obejmuje funkcje członkowskie oraz stałe referencje, których nie ma w
C.
Przykładowo poniższy kod w
C23 doprowadzi do zmiany rzekomej stałej, w
C++ może doprowadzić do wywalenia się programu.
const int i = 4;
int * ptr =( int * ) & i;
* ptr = 5;
printf( "%d\n", i );
Stała referencja w
C++ może wskazywać na wszystko (zarówno na zmienną, jak i wartość chwilową):
int f() { return 4; }
int main()
{
const int & cref = f();
int i = 5;
const int & rref2 = i;
}
Słówko
const
w
C++ może służyć też na oznaczenie metod klasy/struktury (o klasach i strukturach później), przykładowo:
class MyClass
{
public:
void func() const { }
};
słówko to w tym znaczeniu oznacza, że wywołanie danej metody nie modyfikuje składowych klasy... chyba żeby były oznaczone jako
mutable
😀.
Stałe czasu kompilacji - constexpr
W temacie stałych istnieje jeszcze słówko
constexpr
, które oznacza stałą liczoną w trakcie kompilacji.
W
C++ to słowo kluczowe jest dostępne od
C++11, ale kolejne standardy poszerzają możliwości jego użycia. Pojawiło się też ono w
C23 jednak jest ono bardzo ograniczone w stosunku do
C++.
W
C++ słówko to ułatwia korzystanie z metaprogramowania (kod wyliczany w trakcie kompilacji):
#include <iostream>
constexpr unsigned long long fibonacci( unsigned long long n )
{
return 1 == n || 0 == n ?
1ULL: fibonacci( n - 1 ) + fibonacci( n - 1 );
}
int main() {
constexpr auto f15 = fibonacci( 15 );
std::cout << f15 << std::endl; std::cout << fibonacci( 30 ) << std::endl; }
Jak w widać aby funkcja wyliczyła w trakcie kompilacji (w
C++) musi być oznaczona słówkiem
constexpr
, oraz rezultat tej funkcji musi być przypisany do stałej również oznaczonej przez
constexpr
. Co wygodne - funkcja oznaczona tym słówkiem może być również wykonywana w trakcie wykonywania. Oczywiście funkcje do wykonywania w trakcie kompilacji mają pewne ograniczenia co do zawartości (np. brak dynamicznej alokacji pamięci).
W języku
C23 constexpr
służy do zrobienia "prawdziwych" stałych czasu kompilacji, których już nie można zmienić przez wskaźnik, przykładowo:
#include <stdio.h>
constexpr const char s[ ] = "Musicie od siebie wymagać, nawet gdyby inni od was nie wymagali";
int main()
{
printf( "%s\n", s );
const int cint = 4;
int * ptr =( int * ) & cint;
* ptr = 44;
printf( "%d\n", cint ); constexpr const int ccint = 16;
ptr =( int * ) & ccint;
* ptr = 64; printf( "%d\n", ccint );
}
Instrukcje warunkowe
Zarówno
C, jak i
C++ posiadają instrukcje warunkowe
if
i
switch
. W
C++ od wersji
C++17 możliwe jest dodanie inicjalizatora, który definiuje zmienną w zakresie instrukcji. W
C do wersji
C23 włącznie, takie inicjalizatory nie są dostępne.
Przykłady w C++23
#include <iostream>
#include <cstring> int main() {
const char text[ ] = "Przyszłość zaczyna się dzisiaj, nie jutro";
if( auto textLength = std::strlen( text ) )
std::cout << text << std::endl;
if( auto textLength = std::strlen( text ); textLength > 0 ) std::cout << text << std::endl;
switch( auto textLength = std::strlen( text ) ) {
case 0:
std::cout << "(empty text)" << std::endl;
case 1:
std::cout << "(strange text)" << std::endl;
default:
std::cout << text << std::endl;
}
}
Instrukcje warunkowe na etapie kompilacji od C++17
W
C++ od wersji
C++17 możliwe jest rozstrzyganie instrukcji warunkowych w trakcie kompilacji za pomocą
if constexpr
. Pozwala to na wybór kodu do skompilowania w zależności od warunku znanego w czasie kompilacji.
#include <print>
#include <cstdint> int main() {
int i = 4;
if constexpr( sizeof( std::int32_t ) == sizeof( int ) )
std::println( "int zajmuje tyle co int32_t" );
else
std::println( "int różni się rozmiarem od int32_t" );
}
W
C++23 wprowadzono
if consteval
, które pozwala na wykonanie kodu tylko w kontekście wyrażenia stałego, czyli w czasie kompilacji. Jest to przydatne do wywoływania funkcji
consteval
(funkcje oznaczone tym słówkiem muszą być wykonane w czasie kompilacji) z funkcji
constexpr
(które mogą działać zarówno w czasie kompilacji, jak i wykonania), oto przykład:
#include <print>
consteval int square( int n )
{
return n * n;
}
constexpr int compute( int n )
{
if consteval
{
return square( n ); } else
{
return n * n; }
}
int main()
{
constexpr int compile_time_result = compute( 5 ); int x = 5;
int run_time_result = compute( x ); std::println( "Wynik w czasie kompilacji: {}", compile_time_result );
std::println( "Wynik w czasie wykonania: {}", run_time_result );
}
W powyższym przykładzie funkcja
square
jest oznaczona jako
consteval
, więc może być wywołana tylko w czasie kompilacji. Funkcja
compute
, oznaczona jako
constexpr
, używa
if consteval
, aby wywołać
square
tylko w kontekście kompilacji, a w czasie wykonania stosuje alternatywne obliczenie.
if constexpr
i
if consteval
mają różne zastosowania.
if constexpr
wybiera kod do skompilowania na podstawie warunku znanego w czasie kompilacji, natomiast
if consteval
sprawdza, czy kod jest wykonywany w czasie kompilacji, umożliwiając różne zachowanie w czasie kompilacji i wykonania. W
C23 nie ma odpowiedników tych konstrukcji, co podkreśla zaawansowane możliwości
C++ w programowaniu w czasie kompilacji.
Pętle
Zarówno
C jak i
C++ wspierają pętle
for
,
while
i
do - while
.
W
C++ od
C++11 dostępna jest odmiana pętli
for
operująca na zakresie (range-based for loops), które nie istnieją w
C, w tym w
C23. Od
C++17 można do tej pętli dodać inicjalizator, przykładowo:
#include <iostream>
int main() {
const int arr[ ] { 1, 2, 4, 8, 16 };
for( auto element: arr ) std::cout << element << ", ";
std::cout << std::endl;
for( int counter { }; auto element: arr ) std::cout << counter++ << ":" << element << ", ";
std::cout << std::endl;
}
Zerowy wskaźnik
Zarówno
C, jak i
C++ umożliwiają operowanie na surowych wskaźnikach. Wskaźniki, które na nic nie wskazują, powinny być oznaczone jako „puste”. Historycznie w
C i
C++ wykorzystywano do tego celu
NULL
, które zazwyczaj jest makrem zastępowanym przez
0
lub
0L
. Problem polegał na tym, że
NULL
nie ma własnego typu, co mogło prowadzić do niejednoznaczności, np. przy przeciążaniu funkcji w
C++.
Dlatego od
C++11 wprowadzono
nullptr
– dedykowaną stałą wskaźnikową typu
std::nullptr_t
, która eliminuje niejednoznaczność i poprawia czytelność kodu. Dodatkowo, od
C23, również w języku
C dostępna jest stała
nullptr
, a jej typ to
nullptr_t
. Nadal pozostaje dostępny klasyczny
NULL
.
Przykład ilustrujący różnice:
Warto pamiętać, że
NULL
to makro – nie typ – dlatego w
C++ jego stosowanie w kodzie z przeciążeniami funkcji może prowadzić do niejednoznaczności, stąd zawsze preferuj
nullptr
.
Statyczna asercja
Statyczna asercja pozwala na weryfikację warunków w czasie kompilacji. Jeśli warunek nie jest spełniony, kompilacja kończy się błędem z opcjonalną wiadomością (opcjonalną od
C++17).
Rzutowania
Rzutowania to mechanizm konwersji wartości jednego typu danych na inny. W
C podstawowym sposobem rzutowania jest tzw. C-style cast, oznaczany składnią
( type ) expression
lub
type( expression )
. Jest to prosty, ale potencjalnie niebezpieczny mechanizm, ponieważ nie sprawdza zgodności typów i może ukryć błędy.
Przykład w
C23:
#include <stdio.h>
int main()
{
int i = 4;
float * fptr =( float * ) & i; printf( "%f\n", * fptr ); }
W
C++23 dostępne są cztery operatory rzutowania oraz funkcja
std::bit_cast
(od
C++20), które oferują większą precyzję i bezpieczeństwo w porównaniu do C-style cast. C-style cast jest wspierany w
C++ dla kompatybilności, ale odradzany ze względu na ryzyko błędów. Poniżej opisano każdy operator rzutowania z przykładami.
Niejawne rzutowania
Niejawne rzutowania to automatyczne konwersje typów wykonywane przez kompilator bez jawnego polecenia programisty. W
C23 niejawne rzutowania są bardziej liberalne, szczególnie w przypadku wskaźników. Na przykład, wskaźnik
void *
może być automatycznie konwertowany na dowolny inny typ wskaźnika bez jawnego rzutowania, co może prowadzić do błędów, jeśli typy nie są kompatybilne.
W
C++23 niejawne rzutowania są bardziej restrykcyjne, szczególnie dla wskaźników. Konwersja z
void *
na inny typ wymaga jawnego rzutowania (np. za pomocą
static_cast
lub
reinterpret_cast
), co zwiększa bezpieczeństwo kodu.
Restrykcyjność niejawnych rzutowań w C++ zmniejsza ryzyko niezdefiniowanego zachowania, ale wymaga od programisty większej uwagi przy pracy z wskaźnikami. W C23 liberalne podejście ułatwia kodowanie, ale zwiększa ryzyko błędów.
Rzutowanie na tekst i z tekstu
Rzutowanie na tekst (konwersja wartości na ciąg znaków) oraz z tekstu (parsowanie ciągu znaków na wartość) to częste operacje w programowaniu.
Typ na podstawie wyrażenia: decltype vs typeof
W
C++ od wersji
C++11 dostępne jest słowo kluczowe
decltype
, które pozwala określić dokładny typ zmiennej lub wyrażenia. Jest szczególnie przydatne w programowaniu generycznym i szablonach (o tym później). Może też służyć do poznania typu zwracanego przez funkcję.
W
C23 pojawiło się słowo kluczowe
typeof
, które pełni zbliżoną funkcję – pozwala na określenie typu na podstawie wyrażenia lub zmiennej. Wcześniej było dostępne tylko jako rozszerzenie w niektórych kompilatorach (np. GCC).
Dodatkowo w
C23 wprowadzono słowo kluczowe
typeof_unqual
, które ignoruje kwalifikatory typu takie jak
const
czy
volatile
.
decltype
w
C++ jest bardziej precyzyjny niż
typeof
w
C23, bo uwzględnia dokładne kwalifikatory typu (np.
const
, referencje) oraz kontekst użycia. Od
C++14 mamy także
decltype( auto )
, który łączy zalety obu podejść: automatyczną dedukcję z zachowaniem pełnego typu.
typeof_unqual
w
C23 to nowość, która pozwala świadomie pominąć kwalifikatory typu – co może być przydatne np. przy tworzeniu niestałych kopii dla stałych.
Typy wyliczeniowe enum
Typy wyliczeniowe
enum
są dostępne od początków języków
C i
C++. W
C11 i
C++11 wprowadzono możliwość określenia typu bazowego (ang. underlying type), umożliwiając zastąpienie domyślnego
int
innym typem, np.
uint8_t
. W
C++11 dodano także
enum class
(tzw. zakresowe wyliczenia, ang. scoped enumerations), które wymagają kwalifikacji enumeratorów nazwą typu i zapobiegają niejawnej konwersji na liczby całkowite. W obu językach można używać prostych wyliczeń, np.
enum Color { RED, GREEN, BLUE };
.
enum class w C++11
enum class
, w
C++11 zostało wprowadzone aby rozwiązać pewne problemy związane z typami wyliczeniowymi:
Przykład w
C++23:
#include <print>
#include <cstdint> enum class Color
: std::uint8_t
{
RED, GREEN, BLUE
};
int main()
{
Color c = Color::RED;
std::println( "{}", std::to_underlying( c ) ); }
enum o automatycznie wybieranej podstawie (ang. underlying type) w C23
Mamy możliwość podania podstawy typu wyliczeniowego, ale jak jej nie podamy to ... domyślnie był typ
int
. Jednakże w
C23 standard określa, żeby kompilator automatycznie wybrał typ podstawowy, pasujący do zawartości. Tego zachowania nie ma w
C++ do wersji
C++23 włącznie, chociaż niektóre kompilatory (m.in.
g++
) taką funkcjonalność dostarczają. Oto przykład w
C23:
#include <stdio.h>
#include <limits.h>
#include <stdint.h>
enum Big
{
A = - 1,
B = 100000000000ULL, C =( unsigned long long )( INT_MAX ) * 3
};
enum Normal
{
D = 0,
E = 100
};
int main( void )
{
printf( "sizeof int: %zu\n", sizeof( int ) );
printf( "sizeof enum Big: %zu\n", sizeof( enum Big ) );
printf( "sizeof enum Normal: %zu\n", sizeof( enum Normal ) );
}
Powyższy kod w
C++23 nie powinien się kompilować, chociaż
g++
przepuszcza.
Typ logiczny bool
Typ
bool
reprezentuje wartości logiczne:
true
i
false
. W
C++ bool
jest wbudowanym typem od pierwszych wersji języka (
C++98), zajmującym zwykle 1 bajt (nie 1 bit), choć rozmiar jest zależny od implementacji. W
C typ
_Bool
pojawił się w standardzie
C99 jako
_Bool
, z makrami
bool
,
true
i
false
zdefiniowanymi w
< stdbool.h >
. Od
C23 bool
jest pełnoprawnym typem, a
true
i
false
są słowami kluczowymi, co eliminuje konieczność włączania
< stdbool.h >
w nowoczesnych kompilatorach, zwiększając zgodność z
C++.
Przed
C99 standard
C nie definiował typu
bool
. W
C99 i nowszych
_Bool
ma rozmiar zależny od implementacji, zwykle 1 bajt, ale w rzadkich przypadkach może być większy (np. jak
int
).
Uniwersalna inicjalizacja
W
C++11 wprowadzono uniwersalną inicjalizację za pomocą nawiasóww klamrowych
{ }
(ang. uniform initialization), która ujednolica inicjalizację zmiennych, struktur, klas i kontenerów, minimalizując stratne niejawne konwersje. W
C23 inicjalizacja klamrowa jest dostępna dla struktur i tablic, ale mniej elastyczna i nie zapobiega niejawnym konwersjom.
Uniwersalna inicjalizacja w
C++23 jest bardziej bezpieczna dzięki wykrywaniu zawężających konwersji, np. z
double
na
int
. W
C23 inicjalizacja klamrowa jest ograniczona do struktur i tablic, bez takich zabezpieczeń.
Funkcja rozwijana w miejscu wywołania: inline
Słowo kluczowe
inline
, dostępne w
C od wersji
C99 i
C++, sugeruje kompilatorowi wstawienie kodu funkcji bezpośrednio w miejscu wywołania, co może poprawić wydajność kosztem większego kodu binarnego. Słówko to od
C++17 ma dodatkowe znaczenie: pozwala definiować funkcje w nagłówkach bez naruszania
reguły pojedynczej definicji (ODR). W
C++ inline
może być też używane w szablonach i klasach (o tym później), a także pewne funkcje składowe są domyślnie
inline
nawet jak nie użyjemy tego słówka. Wreszcie od
C++17 składowe statyczne można oznaczyć
inline
aby były zdefiniowane od razu (o tym też później).
Przykładowy kod, który powinien działać w obydwu językach (pominąłem nagłówki):
inline int square( int n )
{
return n * n;
}
int main()
{
printf( "%d\n", square( 5 ) ); }
Atrybuty (od C++11 i od C23)
Od
C++11 (ze zmianami w kolejnych standardach), oraz od
C23 są dostępne atrybuty, którymi można oznaczyć pewne fragmenty kodu (funkcje, zmienne itp.). Wpływają one na zachowanie kompilatora (dodatkowe warningi lub ich brak, czy optymalizacje, ale nie tylko). W
C++ mamy dostępne więcej atrybutów.
Przykład w C23 i C++23
Poniższy przykład pokazuje użycie atrybutów
[[ nodiscard ] ]
i
[[ deprecated ] ]
w obu językach, oraz
[[ likely ] ]
w
C++23, który nie jest dostępny w
C23.
Tabela porównawcza atrybutów
Poniższa tabela przedstawia najczęściej używane atrybuty w
C23 i
C++23, ich dostępność oraz krótki opis.
Atrybuty w
C++23 oferują szerszy zestaw narzędzi w porównaniu do
C23, w tym optymalizacje warunkowe (
[[ likely ] ]
,
[[ unlikely ] ]
) i zaawansowane zarządzanie pamięcią (
[[ no_unique_address ] ]
).
Lista artybutów nie jest zamknięta, kompilatory oferują własne atrybuty.
Alokacja pamięci - zapowiedź
Alokacja pamięci - czyli dynamiczne pozyskiwanie (od systemu operacyjnego) pamięci dla programu w trakcie jego wykonywania. Zagadnienie to zostanie opisane później, gdyż w
C do alokacji pamięci służą funkcje
malloc
,
calloc
,
realloc
(dostępne w
#include <stdlib.h>
). Z kolei w
C++ do tego celu służą operatory
new
(wiele wersji). Stąd opisze to później gdy będę porównywał od strony biblioteki standardowej.
Funkcjonalności występujące w obydwu językach ale o drastycznych różnicach
C i
C++ mają wiele cech wspólnych z drobnymi różnicami. Są jednak wspólne zagadnienia ale tak diametralnie różniące się, ze aby je porównać trzeba by tak właściwie porządnie opisać obydwa, stąd podrzucę jedynie przykłady.
Struktury
Struktury w obydwu językach robi się przy pomocy słowa kluczowego
struct
. W języku
C istnieje możliwość tworzenia agregatów na dane różnego typu - struktury. Oto przykład w języku
C:
#include <stdio.h>
#include <string.h> struct Employee
{
char name[ 50 ];
int id;
float salary;
};
void print_employee( struct Employee emp )
{
printf( "Pracownik: %s, ID: %d, Wynagrodzenie: %.2f\n", emp.name, emp.id, emp.salary );
}
int main()
{
struct Employee emp;
strcpy( emp.name, "Jan Kowalski" );
emp.id = 1234;
emp.salary = 5000.75;
print_employee( emp ); }
W
C++ struktury mają znacznie poszerzone funkcjonalności, do tego stopnia, że wspierają paradygmat wyższego poziomu - programowanie obiektowe zamiast programowania strukturalnego. Struktury w
C++ mogą mieć metody, wspólne składowe (
static
), enkapsulację składowych, dziedziczyć po innych strukturach i znacznie więcej. Jednakże konwencjonalnie struktury w
C++ stosuje się niemalże jak w
C - bez tych zaawansowanych funkcjonalności. Jeśli znasz tylko
C, to się zapewne zastanawiasz dlaczego się nie stosuje tych zaawansowanych funkcjonalności, jakie mają struktury w
C++: wynika to z konwencji, że do zaawansowanej funkcjonalności stosuje się klasy (słówko
class
) zamiast struktur.
W
C++ nie trzeba używać słówka
struct
przed każdą definicją zmiennej, która jest instancją struktury.
Unie w C i C++
Unie (
union
) pozwalają przechowywać różne typy danych w tej samej pamięci. W
C23 unie są proste: wszystkie składowe dzielą tę samą pamięć, a programista zarządza dostępem. W
C++ od wersji
C++11 unie mogą zawierać typy z nietrywialnymi konstruktorami, ale dostęp do składowych musi być zgodny z aktywną składową, aby uniknąć niezdefiniowanego zachowania.
W
C++ nie trzeba używać słówka
union
przed każdą definicją zmiennej, która jest instancją uni.
Kompatybilność binarna
Mimo różnic między językami jest możliwe utworzenie programu, z którego część napisana jest w
C, a część w
C++. Do tego celu na styku między językami po stronie
C++ musi być zastosowana odpowiednia konstrukcja:
extern "C"
. Zapobiega ona manglowaniu nazw funkcji w
C++, które w
C++ są modyfikowane w celu wsparcia przeciążania funkcji (dodając informacje o typach parametrów). W
C nazwy funkcji pozostają niezmienione po kompilacji, co wymaga użycia
extern "C"
w
C++ dla zachowania kompatybilności.
Poniżej przedstawiono humorystyczny przykład programu, w którym moduł w
C zarządza krową wydającą dźwięki, a moduł w C++ wyświetla jej działania w bardziej „elegancki” sposób, korzystając z biblioteki standardowej
C++.
Plik nagłówkowy (cow.h)
#ifndef COW_H
#define COW_H
struct Cow
{
char name[ 50 ];
int grass_eaten;
};
struct Cow * create_cow( const char * name );
void cow_moo( struct Cow * cow );
void cow_eat_grass( struct Cow * cow, int grass );
void destroy_cow( struct Cow * cow );
#endif
Implementacja w C (cow.c)
#include "cow.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct Cow * create_cow( const char * name )
{
struct Cow * cow = malloc( sizeof( struct Cow ) );
if( cow )
{
strncpy( cow->name, name, 49 );
cow->name[ 49 ] = '\0';
cow->grass_eaten = 0;
}
return cow;
}
void cow_moo( struct Cow * cow )
{
printf( "%s mówi: Muuu! Muuu!\n", cow->name );
}
void cow_eat_grass( struct Cow * cow, int grass )
{
cow->grass_eaten += grass;
printf( "%s zjadła %d porcji trawy. Łącznie zjedzone: %d\n", cow->name, grass, cow->grass_eaten );
}
void destroy_cow( struct Cow * cow )
{
free( cow );
}
Program główny w C++ (main.cpp)
#include <print>
extern "C"
{
#include "cow.h"
}
int main() {
auto * cow = create_cow( "Bessie" );
if( !cow )
{
std::println( "Oj, nie udało się stworzyć krowy!" );
return 1;
}
std::println( "Witaj w symulatorze krowy!" );
cow_moo( cow );
cow_eat_grass( cow, 5 );
cow_moo( cow );
cow_eat_grass( cow, 3 );
std::println( "Krowa {} zakończyła swoją przygodę!", cow->name );
destroy_cow( cow );
}
Kompilacja i uruchomienie (na Linuxie, kompilator gcc)
Aby skompilować program, należy użyć kompilatorów dla
C i
C++ oraz połączyć obiekty w jeden program:
gcc --std=c23 -c cow.c -o cow.o # kompilowanie kodu w C
g++ --std=c++23 -c main.cpp -o main.o # kompilowanie kodu w C++
g++ main.o cow.o -o cow_simulator # linkowanie
./cow_simulator # uruchamianie
Wynik uruchomienia programu:
Witaj w symulatorze krowy!
Bessie mówi: Muuu! Muuu!
Bessie zjadła 5 porcji trawy. Łącznie zjedzone: 5
Bessie mówi: Muuu! Muuu!
Bessie zjadła 3 porcji trawy. Łącznie zjedzone: 8
Krowa Bessie zakończyła swoją przygodę!
Użycie
extern "C"
w
C++ pozwala na wywołanie funkcji z
C bez problemów z manglowaniem nazw. To kluczowy mechanizm umożliwiający kompatybilność binarną między
C i
C++. Przykład pokazuje, jak moduł w C może być zintegrowany z kodem
C++, łącząc prostotę
C z nowoczesnymi możliwościami
C++, takimi jak
std::println
. W ten sposób programiści mogą korzystać z zalet obu języków w jednym projekcie.
W powyższym przykładzie pokazałem jak używać kodu
C w
C++. Jednakże jest możliwe również w drugą stronę. Sposób "wystawiania interfejsu" po stronie
C++ jest przez
extern "C"
, ale poza tym są pewne funkcjonalności
C++, niedostępne w
C, które nie powinny "lecieć między interfejsami", są to wyjątki (o nich później).
Funkcjonalności C++, których nie ma w C
C++ jest znacznie bardziej zaawansowanym językiem programowania niż
C. Regularne nowe standardy polepszają składnię, dodając nowe funkcjonalności. Wręcz twórca języka
C++ wystosował
list otwarty, w którym prosi, aby komitet standaryzacyjny zlitował się nad programistami i przestał tak bardzo komplikować język (składnię). Te funkcjonalności mają służyć temu, aby doświadczony programista
C++ mógł napisać kod wydajniejszy niż w czymkolwiek innym (nie licząc asemblera), ale jest to trudne. Język
C z kolei uchodzi od dawna za prosty język.
Wymienione poniżej funkcjonalności są zademonstrowane tylko na najprostszych przykładach, każdą z nich należy doczytać na własną rękę szczegółowo, najlepiej z dobrej książki do
C++ (polecam
Opus Magnum Grębosza).
Poniżej lista funkcjonalności, które są w
C++, ale nie ma ich w
C, do wersji
C23 włącznie:
Przestrzenie nazw
C++ wspiera przestrzenie nazw do organizowania kodu. Przestrzenie nazw są jak foldery w systemie – każdy folder może zawierać elementy o tych samych nazwach (m.in. funkcje czy zmienne), ale dzięki różnym przestrzeniom nazw nie ma konfliktów. Kolejną zaletą jest grupowanie powiązanych elementów, co zwiększa czytelność kodu.
Przestrzenie nazw w C++23 eliminują konflikty nazw i poprawiają organizację kodu, czego brak w C23, gdzie konflikty rozwiązuje się ręcznie, np. przez prefiksy w nazwach funkcji.
Moduły (od C++20)
Jest to alternatywa do włączania nagłówków, zamiast
#include
piszemy
import
. Zaletą jest szybkość kompilacji (jak nagłówki prekompilowane) oraz wygoda. Jest to funkcjonalność, którą od dawna oferowały kompilatory, jednak dopiero od
C++20 jest ustandaryzowana.
Plik helloworld.cpp
export module helloworld; import < iostream >; export void hello() {
std::cout << "Hello world!\n";
}
Plik main.cpp
import helloworld;
int main()
{
hello();
}
Moduły w C++23 upraszczają zarządzanie zależnościami i znacząco przyspieszają kompilację w porównaniu do tradycyjnych nagłówków w C++. Dzięki nim kompilator nie musi wielokrotnie przetwarzać tych samych plików nagłówkowych, co eliminuje tzw. "include hell" i poprawia skalowalność dużych projektów.
Chociaż moduły zostały ustandaryzowane już w C++20, a C++23 jedynie je dopracowuje, to wiele kompilatorów (szczególnie GCC) nadal nie wspiera ich w pełni lub wymaga specjalnej konfiguracji do poprawnej kompilacji z użyciem modułów.
Obiektowość: klasy i struktury
Jest to funkcjonalność wspierająca na poziomie języka programowanie obiektowe. Użytkownik może utworzyć własny typ, zwany klasą, przy użyciu słówka kluczowego
class
(lub
struct
, choć konwencjonalnie struktury służą jako typ do przechowywania danych bez logiki). Klasa poza danymi (jak struktura w
C) może posiadać metody. Dane i metody mogą być ukrywane, a sposób przechowywania danych może być różny (m.in. składowe statyczne, które są niezależne od konkretnej zmiennej, będącej instancją danego typu).
Co specyficzne dla
C++ – klasy mogą mieć zdefiniowaną własną logikę dla operatorów – tzw. przeciążanie operatorów. A operatory można przeciążyć prawie wszystkie (
więcej o przeciążaniu operatorów).
Klasy mogą po sobie „dziedziczyć” – przejmować prawie całą zawartość.
C++, w przeciwieństwie do wielu języków obiektowych, pozwala na dziedziczenie wielobazowe.
Klasy w C++23 wspierają programowanie obiektowe z metodami i przeciążaniem operatorów, czego brak w C23, gdzie struktury są ograniczone do przechowywania danych bez logiki.
Polimorfizm (dynamiczny)
W oparciu o dziedziczenie, wskaźniki/referencje dla klas i metody wirtualne w
C++ dostępny jest polimorfizm. Wskaźnikiem/referencją na klasę bazową możemy wskazywać na klasy pochodne, dzięki czemu wywołując funkcje składowe, automatycznie jest wywoływana metoda z obiektu, na który wskazujemy, a nie z klasy bazowej. Zmniejsza to zależności w kodzie i pozwala pisać bardziej ogólny kod, bazujący na klasie-wzorcu (tzw. interfejsie), choć wiąże się to z pewnym narzutem wydajności.
Poniżej przykładowy kod w
C++:
#include <print>
#include <iostream>
#include <cmath>
class Shape
{
public:
virtual bool isIn( float x, float y ) const = 0;
virtual ~Shape() = default;
};
class Rectangle
: public Shape
{
float xFrom, yFrom, xTo, yTo;
public:
Rectangle( float xFrom, float yFrom, float xTo, float yTo )
: xFrom
{ xFrom }, yFrom { yFrom }, xTo { xTo }, yTo { yTo }
{ }
bool isIn( float x, float y ) const override
{
return xFrom <= x && x <= xTo && yFrom <= y && y <= yTo;
}
};
class Circle
: public Shape
{
float radius;
float x, y;
public:
Circle( float xCenter, float yCenter, float radius )
: x
{ xCenter }, y { yCenter }, radius( radius )
{ }
bool isIn( float x, float y ) const override
{
const auto xDifference = std::abs( x - this->x );
const auto yDifference = std::abs( y - this->y );
const auto distance2XYFromCenter = static_cast < decltype( radius ) >( std::round( std::sqrt( std::pow( xDifference, 2 ) +
std::pow( yDifference, 2 ) ) ) );
return distance2XYFromCenter <= radius;
}
};
void drawShape( const Shape & shape, int stageHeigh, int stageWidth )
{
const auto length4YLabel = ceil( log10( stageHeigh ) ) + 1;
for( decltype( stageHeigh ) y = stageHeigh; y >= 0; --y )
{
std::cout << y << ": ";
for( decltype( stageWidth ) x = 0; x <= stageWidth; ++x )
{
if( shape.isIn( x, y ) )
{
std::cout << '*';
}
else
{
std::cout << ' ';
}
}
std::cout << '\n';
}
for( decltype( stageWidth ) x = 0; x <= stageWidth; ++x )
{
std::cout <<( x % 10 );
}
std::cout << '\n';
}
int main()
{
constexpr int N = 5;
Shape * shapes[ N ] =
{
new Rectangle( 1, 1, 3, 3 ),
new Rectangle( 4, 0, 8, 2 ),
new Circle( 2, 7, 2 ),
new Circle( 7, 7, 1.5 ),
new Rectangle( 5, 5, 5, 5 ) };
for( int i = 0; i < N; ++i )
{
std::println( "Shape #{}:", i + 1 );
drawShape( * shapes[ i ], 9, 9 );
std::cout << '\n';
}
for( int i = 0; i < N; ++i )
delete shapes[ i ];
}
Wydruk z powyższego kodu:
Shape #1:
9:
8:
7:
6:
5:
4:
3: ***
2: ***
1: ***
0:
0123456789
Shape #2:
9:
8:
7:
6:
5:
4:
3:
2: *****
1: *****
0: *****
0123456789
Shape #3:
9: ***
8: *****
7: *****
6: *****
5: ***
4:
3:
2:
1:
0:
0123456789
Shape #4:
9:
8: ***
7: ***
6: ***
5:
4:
3:
2:
1:
0:
0123456789
Shape #5:
9:
8:
7:
6:
5: *
4:
3:
2:
1:
0:
0123456789
Jak widać możemy tworzyć klasy, które sobie dziedziczą pewien interfejs implementując pewne metody po swojemu. Pisząc kod, możemy operować na interfejsie klasy bazowej, bez konieczności znajomości konkretnego typu pochodnego.
Polimorfizm w C++23 umożliwia dynamiczne wywoływanie metod klas pochodnych, czego brak w C23, gdzie podobne efekty wymagają ręcznego zarządzania typami.
Semantyka przenoszenia
Jeśli mamy obiekt, który zaraz przestanie istnieć, a jego zawartość jest potrzebna, to zamiast go kopiować (aby potem usunąć oryginał), warto przenieść jego zawartość. Taka sytuacja ma miejsce, gdy funkcja zwraca obiekt. Mechanizm ten mocno wiąże się z tzw. prawymi referencjami, które pozwalają na efektywną obsługę tymczasowych obiektów.
Przykładowa klasa, która ma zdefiniowane przenoszenie:
#include <cstring> class Book
{
char * text;
public:
Book()
: text
{ nullptr }
{ }
Book( const Book & book )
: text
{ book.text ? new char[ strlen( book.text ) + 1 ]
: nullptr }
{
if( book.text )
strcpy( text, book.text );
}
Book( Book & book )
: text
{ book.text }
{
book.text = nullptr;
}
};
Książka może być duża, alokacja pamięci i kopiowanie zawartości może być wolne, a czasami wystarczy przenieść.
Semantyka przenoszenia w C++23 zwiększa wydajność przez unikanie kopiowania. W C23 przenoszenie nie jest wspierane przez język, jeśli je chcemy to musimy sobie zaimplementować samodzielnie, dodam, że kopiowanie danych z zasobami też trzeba sobie zaimplementować samodzielnie.
Obsługa wyjątków
Jest to mechanizm pomagający w obsłudze błędów. Dzięki niemu piszemy kod, nie musząc sprawdzać kodów błędów zwracanych przez funkcje. Po prostu w razie wykrycia błędów zostaje rzucony wyjątek (w
C++ może to być dowolny obiekt), który może być łapany przez nawet wiele poziomów wywołania wcześniej. Jest to bezpieczne, łatwiej się reaguje na błędy. Można tworzyć własne wyjątki, aby lepiej opisywać błąd, który wystąpił.
Obsługa wyjątków w C++23 upraszcza zarządzanie błędami, w przeciwieństwie do C23, gdzie wymaga to ręcznej obsługi kodów błędów.
Szablony
C++ wspiera bezpośrednio programowanie generyczne poprzez mechanizm szablonów. Szablon to „przepis na wygenerowanie kodu” — konkretna wersja funkcji, klasy lub aliasu tworzona jest przez kompilator w momencie użycia, gdy zostaną podstawione odpowiednie typy.
Wyróżniamy szablony:
Dzięki szablonom możemy napisać kod tylko raz, a kompilator wygeneruje odpowiednie wersje dla różnych typów. To bardzo przydatne np. w implementacji algorytmów sortowania, porównywania czy operacji matematycznych, które powinny działać niezależnie od typu danych.
Szablony w C++23 pozwalają pisać generyczny, wielokrotnego użytku kod bez potrzeby powielania funkcji dla każdego typu danych. W C23, gdzie nie ma szablonów, trzeba ręcznie pisać oddzielne wersje funkcji dla każdego typu, co prowadzi do większej ilości kodu i większej podatności na błędy.
Szablon to "przepis jak wygenerować kod", stąd definicje szablonów najczęściej umieszcza się w plikach nagłówkowych. Co prawda są sposoby aby to "obejść" ale są uciążliwe i niepraktyczne.
Wyrażenia lambda (od C++11, rozwinięte w kolejnych standardach)
Lambdy to funkcje anonimowe (technicznie funktory), które można zdefiniować bezpośrednio w miejscu wywołania. Mogą przechwytywać zmienne z otoczenia przez wartość lub referencję i doskonale nadają się do krótkich operacji, np. jako argumenty funkcji wyższego rzędu.
Wyrażenia lambda w C++23 zwiększają czytelność i elastyczność kodu, pozwalając tworzyć funkcje „na miejscu”. W C23 trzeba posługiwać się nazwanymi funkcjami i wskaźnikami do nich.
Warto pamiętać, że definiowanie funkcji wewnątrz innych funkcji jest niedozwolone zarówno w C++, jak i w C. Niektóre kompilatory mogą to jednak dopuszczać jako niestandardowe rozszerzenie.
Funkcje wykonywane wyłącznie w czasie kompilacji:
consteval
Słowo kluczowe
consteval
, dodane w
C++20, oznacza funkcje, które muszą zostać wywołane w czasie kompilacji. W przeciwieństwie do
constexpr
, które mogą działać w czasie kompilacji lub wykonania,
consteval
zawsze generuje stałą w czasie kompilacji.
consteval
w
C++23 pozwala pisać funkcje gwarantująco wywoływane w czasie kompilacji. W
C23 można używać tylko makr, które są mniej bezpieczne, trudniejsze do debugowania i pozbawione typów.
Stała inicjalizacja zmiennych statycznych od C++20
Słowo kluczowe
constinit
, wprowadzone w
C++20, wymusza, by zmienna o statycznym okresie przechowywania (np. globalna,
static
lokalna) została
zainicjalizowana w czasie kompilacji. Chroni to przed tzw. inicjalizacją dynamiczną, która może prowadzić do błędów w kolejności inicjalizacji między jednostkami translacji (tzw.
static initialization order fiasco).
constinit
pozwala upewnić się, że zmienna o statycznym okresie życia jest zainicjalizowana
na etapie kompilacji, co zwiększa bezpieczeństwo i eliminuje problemy z kolejnością inicjalizacji globalnych obiektów.
Słowo kluczowe
constinit
nie oznacza, że zmienna jest
const
– wartość może być później modyfikowana. Wymaga jedynie, by
pierwotna inicjalizacja nastąpiła w czasie kompilacji. W
C23 nie ma odpowiednika – możliwe jest tylko
const
, które oznacza niezmienność wartości, ale nie wymusza inicjalizacji w czasie kompilacji.
Funkcjonalności C, których nie ma w C++ (do wersji C++23 włącznie)
Można odnieść pozorne wrażenia, że co wolno w
C to wolno i w
C++ - może często tak jest, ale nie zawsze. Są funkcjonalności, które są dozwolone tylko w
C. Oczywiście może się zdarzyć, że kompilatory mają wbudowane rozszerzenia i dostarczają taką, niedozwoloną przez standard, funkcjonalność.
Tablice zmiennej długości (VLA, ang. Variable Length Arrays)
Od
C99 wspierane są tablice o zmiennej długości (VLA), których rozmiar jest określany w czasie wykonania. W
C++ VLA nie są częścią standardu, choć niektóre kompilatory je wspierają jako rozszerzenie (np.
g++
).
C23 nadal wspiera VLA.
Przykład w C23
#include <stdio.h>
int main( void )
{
int n = 10;
int arr[ n ];
int arr2[ n ] = { }; for( int i = 0; i < n; ++i )
printf( "%d, ", arr2[ i ] );
puts( "" );
}
W
C++23 powyższy kod jest niezgodny ze standardem, ale niektóre kompilatory mogą to "przepuścić" np.
g++
.
Tablice zmiennej długości wydają się wygodne w użyciu, jednak jeśli spróbujemy utworzyć zbyt dużą tablicę, to doprowadzi to do wywalenia się programu.
Osadzanie plików binarnych w kodzie źródłowym: #embed (tylko C23)
Nowa dyrektywa preprocesora
#embed
, dodana w standardzie
C23, umożliwia
osadzenie zawartości pliku binarnego lub tekstowego bezpośrednio w programie jako tablicy bajtów. Dzięki temu nie trzeba posługiwać się zewnętrznymi narzędziami (jak np.
xxd
,
objcopy
) do konwersji danych binarnych na C-owe tablice. Danymi takimi mogą być np. osadzane obrazy, pliki konfiguracyjne, certyfikaty, dźwięki, shadery itp.
Optymalizacje przez ograniczenie aliasowania: restrict (tylko C)
Od standardu
C99 język
C wprowadził słowo kluczowe
restrict
, które informuje kompilator, że dany wskaźnik jest jedynym (lub pierwszym) sposobem dostępu do danego obszaru pamięci. Dzięki temu kompilator może bezpieczniej i agresywniej optymalizować operacje, eliminując konieczność sprawdzania aliasowania (czy dwa wskaźniki nie wskazują na tę samą pamięć).
W
C++ brak jest odpowiednika
restrict
w standardzie – choć niektóre kompilatory wspierają rozszerzenia zbliżone semantycznie (np.
__restrict__
w GCC/Clang lub
__restrict
w MSVC), nie są one przenośne ani ustandaryzowane.
#include <stdio.h>
void add_arrays( size_t n, int * restrict a, int * restrict b, int * restrict result )
{
for( size_t i = 0; i < n; ++i )
result[ i ] = a[ i ] + b[ i ];
}
int main()
{
int a[ ] = { 1, 2, 3 };
int b[ ] = { 4, 5, 6 };
int result[ 3 ];
add_arrays( 3, a, b, result );
for( int i = 0; i < 3; ++i )
printf( "%d ", result[ i ] ); }
Słowo kluczowe
restrict
może znacząco poprawić wydajność w kodzie krytycznym wydajnościowo (np. operacje na dużych tablicach), ale jego użycie wymaga pewności, że wskaźniki rzeczywiście nie wskazują na ten sam obszar pamięci.
Biblioteka standardowa
Biblioteka standardowa języka
C23, wg
CppReference.com, składa się z 32 plików nagłówkowych. Każdy z plików nagłówkowych języka
C może być włączony w języku
C++ (istnieją wyjątki, m.in.
< threads.h >
nie istnieje dla
C++, który ma swoje
< threads >
), ale są dwa sposoby jego włączania:
Pierwszy sposób jest niezalecany, gdyż jest mniej kompatybilny z konwencjami
C++, np. nie jest w przestrzeni nazw
std::
, przez co łatwiej o konflikty nazw. Poza tym nie ma wersji przeciążonych funkcji.
C++ do wersji
C++23 ma około 90 różnych nagłówków + nagłówki z języka
C.
Jeśli chcemy się przesiąść z jednego języka na drugi to nie mamy wyjścia - należy poczytać jakie nagłówki są dostępne i co zawierają. W języku
C jest to łatwiejsze, bo jest ich mniej. W przypadku
C++ już jest więcej, ale pierwszym do zapoznania się są
kontenery standardowe (alternatywnie
podstawowe kontenery na Cpp0x.pl).
Dynamiczna alokacja pamięci
Dynamiczna alokacja pamięci umożliwia przydzielenie pamięci w czasie działania programu, zamiast statycznie w trakcie kompilacji.
W
C od bardzo dawna (do wersji
C23 włącznie) do tego celu używa się funkcji z biblioteki standardowej:
malloc
,
calloc
,
realloc
oraz
free
. Funkcje te wymagają nagłówka
#include <stdlib.h>
. Programista musi sam zadbać o poprawne zaalokowanie i zwolnienie pamięci, a także o odpowiednie zainicjalizowanie danych – nie ma konstruktorów ani destruktorów.
W
C++23 używa się operatorów
new
i
delete
, które nie tylko alokują i zwalniają pamięć, ale także automatycznie wywołują konstruktory i destruktory dla obiektów.
Zarządzanie pamięcią
C++23 przez operatory
new
/
new[ ]
oraz ich odpowiedniki
delete
/
delete[ ]
jest łatwiejszy niż użycie funkcji w
C23. Oczywiście w razie potrzeby w
C++ możemy użyć funkcji z
C do alokacji pamięci.
W
C++ mamy wiele możliwości alokacji pamięci ważne jest jednak aby sposób zwalniania pamięci był odpowiedni do sposobu jej alokacji. Nie przestrzeganie tej zasady to niezdefiniowane zachowanie, które może skutkować wywaleniem się programu. Tak więc jeśli alokujemy przez
new
to zwalniamy przez
delete
a nie
delete[ ]
, czy
free()
; analogicznie dla pozostałych sposobów alokacji.
W
C++ nie można zaalokować tablicy obiektów za pomocą
new T[ n ]
, jeśli typ
T
nie ma domyślnego konstruktora. Wynika to z tego, że
new[ ]
wywołuje konstruktor domyślny dla każdego elementu. Aby to obejść, można użyć kontenera np.
std::vector
z odpowiednią inicjalizacją (to jest rozwiązanie zalecane) lub tablic dynamicznych ze wskaźnikami przy użyciu globalnego operatora
new
.
Inteligentne wskaźniki zamiast ręcznego zarządzania pamięcią
Zwalnianie zasobów (zwłaszcza pamięci) jest bardzo istotne, dlatego do każdej instrukcji alokacji należy używać odpowiadającą mu instrukcje zwalniającą pamięć. Niestety jak to w praktyce bywa zdarzają się sytuacje, że funkcja "wyskoczy" przed dojściem do instrukcji zwalniającej pamięć. Jest tak za sprawą znanych z
C instrukcji takich jak
return
, czy obsługa sygnałów, ale
C++ wprowadza również mechanizm wyjątków. Dlatego mimo najlepszych starań najlepszego programisty może dojść do tzw. wycieku pamięci (ang. memory leak). Na szczęście w
C++ powstały tzw. inteligentne wskaźniki, które utworzone na stosie (stos jest automatycznie zarządzany) w destruktorze zwalniają pamięć dynamiczną, dzięki czemu unikamy wycieków pamięci. W standardzie
C++ mamy następujące inteligentne wskaźniki (w nagłówku
#include <memory>
):
Nigdy nie mieszaj metod zarządzania pamięcią: nie używaj
delete
na obiekcie zarządzanym przez inteligentne wskażniki. Pamiętaj też, żeby dla tablic skorzystać z wersji tablicowej inteligentnego wskażnika.
Różnice na pograniczu biblioteki standardowej i języka
Istnieją funkcjonalnośći, które mimo iż wymagają włączenia nagłówków to stosują pewną "magię" wbudowaną w kompilator, stąd sami nie byli byśmy w stanie ich zaimplementować w sposób przenośny bazując jedynie na standardzie.
Stacktrace
W
C++23 dostępna w standardzie jest biblioteka
< stacktrace >
, umożliwiająca przechwytywanie i wyświetlanie śladu stosu wywołań w czasie działania programu. Ułatwia to debugowanie — można analizować wywołania jako tekst lub przeglądać konkretne ramki.
W
C23 brak natywnego wsparcia dla
stacktrace
: programiści są skazani na zewnętrzne biblioteki (np.
libbacktrace) lub debuggera (np.
gdb
).
#include <print>
#include <stacktrace>
void foo()
{
auto trace = std::stacktrace::current();
std::println( "Stacktrace:\n{}", trace );
}
void bar()
{
foo();
}
int main()
{
bar();
}
Informacja o miejscu w kodzie: plik, linia, funkcja
W
C++20 dodano
std::source_location
, które pozwala uzyskać informacje o miejscu wywołania (plik, linia, funkcja) w czasie kompilacji. Przydaje się w logowaniu, testowaniu i diagnostyce.
W
C23 (oraz przed
C++20) można użyć makr preprocesora:
__FILE__
,
__LINE__
,
__func__
, ale trzeba je przekazywać ręcznie i nie są tak elastyczne. Można wtedy się pokusić o wsparcie od preprocesora.
std::source_location automatyzuje przekazywanie informacji o miejscu w kodzie, co czyni logowanie czytelniejszym i mniej podatnym na błędy.
Dynamiczne rozpoznawanie typów - mini refleksja
W
C++ operator
typeid
pozwala uzyskać informacje o typie obiektu w czasie działania (
RTTI). Można wyświetlić nazwę typu, czy ID typu, ale jest to bardzo ograniczone ... i ma pewien narzut na wykonywaniu. Z tego samego mechanizmu korzysta
dynamic_cast
gdy potrzebujemy rzutowania bazowej klasy do konkretnej pochodnej lub do diagnostyki.
Przykład w C++:
#include <print>
#include <typeinfo> struct Shape
{
virtual ~Shape() = default;
};
struct Circle
: Shape
{ };
struct A { }; struct B
: A
{ };
int main() {
Circle c;
const Shape & s = c;
std::println( "Typ: {}", typeid( s ).name() ); B b;
const A & a = b;
std::println( "Typ: {}", typeid( a ).name() ); int i { };
const int ci = i;
const int & cref = i;
std::println( "Mamy int, wykrywa: {}", typeid( i ).name() ); std::println( "Mamy const int, wykrywa: {}", typeid( ci ).name() ); std::println( "Mamy const int &, wykrywa: {}", typeid( cref ).name() ); if( typeid( i ) == typeid( ci ) ) std::println( "Wg typeid int to const int" );
}
Jak widać mechanizm ten nie jest doskonały - nie wyświetla dokładnych nazw typów, oraz gubi kwalifikatory i referencje. Poza tym przy hierarchii klas wymaga metody virtualnej w klasie bazowej aby rozpoznać obiekt klasy pochodnej.
typeid
umożliwia introspekcję typu w czasie wykonania – bez potrzeby ręcznego śledzenia nazw czy ID typów. Niestety ten mechanizm jest bardzo ograniczony.
Jeśli ktoś bardzo potrzebuje tego mechanizmu może skorzystać z zewnętrznej biblioteki, poniżej przykład dla
Boost_typeindex:
#include <print>
#include <boost/type_index.hpp>
namespace bt = boost::typeindex;
struct Shape
{
virtual ~Shape() = default;
};
struct Circle
: Shape
{ };
struct A { }; struct B
: A
{ };
int main() {
Circle c;
const Shape & s = c;
std::println( "{}", bt::type_id_runtime( s ).pretty_name() ); std::println( "{}", bt::type_id_with_cvr < decltype( s ) >().pretty_name() ); B b;
const A & a = b;
std::println( "{}", bt::type_id_runtime( a ).pretty_name() ); std::println( "{}", bt::type_id_with_cvr < decltype( a ) >().pretty_name() ); int i { };
const int ci = i;
const int & cref = i;
std::println( "{}", bt::type_id_runtime( i ).pretty_name() ); std::println( "{}", bt::type_id_runtime( ci ).pretty_name() ); std::println( "{}", bt::type_id_with_cvr < decltype( ci ) >().pretty_name() ); std::println( "{}", bt::type_id_runtime( cref ).pretty_name() ); std::println( "{}", bt::type_id_with_cvr < decltype( cref ) >().pretty_name() ); if( bt::type_id_runtime( i ) == bt::type_id_runtime( ci ) ) std::println( "Wg type_id_runtime 'int' to 'const int'" );
}
W
C23 nie ma RTTI; typy muszą być zarządzane ręcznie – np. przez pola opisujące typ lub enumy.
Korutyny (ang. coroutines, od C++20)
W
C++20 wprowadzono korutyny (dostępne w
C++23, nagłówek
< coroutine >
), które ułatwiają pisanie kodu asynchronicznego np. do tworzenia generatorów lub obsługi operacji I/O. Korutyna to specjalny rodzaj funkcji, której wykonanie można wstrzymać (np.
co_yield
) i później wznowić. W
C++20/23 są bezstosowe – stan jest trzymany poza stosem.
W poniższym przykładzie mamy dwa podejścia do pisania serwera ECHO (czyli zwraca co otrzymał). Serwer ten charakteryzuje się tym, że nie wiadomo kiedy coś otrzyma, nie wiadomo ile zapytań równocześnie. Oraz ile zajmie użytkownikowi po połączeniu wysłanie wiadomości - widać jak ważna jest tutaj asynchroniczność:
Korutyny w C++23 oferują eleganckie podejście do programowania asynchronicznego, niedostępne w C23, gdzie asynchroniczność wymaga ręcznego zarządzania callbackami lub wątkami. To znacząco upraszcza kod w C++.
Bonus: kod działający zarówno w C, jak i C++, wypisujący informacje o środowisku kompilacji
Choć
C++ to prawie nadzbiór
C, nie każdy kod napisany w
C będzie akceptowany przez kompilator
C++ (np. brak rzutowania typów
void *
, niektóre makra, stare style deklaracji funkcji itd.). Jednak dobrze napisany kod w
C może być bez problemu użyty w
C++ – wystarczy zadbać o odpowiednią kompatybilność.
Poniżej przedstawiamy
kompatybilny nagłówek i kod, który można skompilować zarówno jako
C, jak i
C++. Kod ten:
Plik: common_features.h
#ifndef COMMON_FEATURES_H
#define COMMON_FEATURES_H
#ifdef __cplusplus
extern "C" {
#endif
void print_language_info( void );
#ifdef __cplusplus
}
#endif
#endif
Plik: common_features.c lub common_features.cpp
#include "common_features.h"
#ifdef __cplusplus
#include <cstdio>
#else
#include <stdio.h>
#endif
void print_language_info( void )
{
printf( "==== Informacje o środowisku kompilacji ====\n" );
#ifdef __cplusplus
printf( "Język: C++\n" );
printf( "Standard: C++%ld\n", __cplusplus );
#else
printf( "Język: C\n" );
# ifdef __STDC_VERSION__
printf( "Standard: C%ld\n", __STDC_VERSION__ );
# else
printf( "Standard: brak informacji (__STDC_VERSION__ niezdefiniowany)\n" );
# endif
#endif
#if defined(__clang__)
printf( "Kompilator: Clang %d.%d.%d\n", __clang_major__, __clang_minor__, __clang_patchlevel__ );
#elif defined(__GNUC__)
printf( "Kompilator: GCC %d.%d.%d\n", __GNUC__, __GNUC_MINOR__, __GNUC_PATCHLEVEL__ );
#elif defined(_MSC_VER)
printf( "Kompilator: MSVC %d\n", _MSC_VER );
#else
printf( "Kompilator: Nieznany\n" );
#endif
#if defined(_WIN32)
printf( "System: Windows\n" );
#elif defined(__linux__)
printf( "System: Linux\n" );
#elif defined(__APPLE__)
printf( "System: macOS\n" );
#elif defined(__unix__)
printf( "System: UNIX (ogólny)\n" );
#else
printf( "System: Nieznany/niestandardowy\n" );
#endif
#if defined(__x86_64__) || defined(_M_X64)
printf( "Architektura: x86_64 (64-bit)\n" );
#elif defined(__i386__) || defined(_M_IX86)
printf( "Architektura: x86 (32-bit)\n" );
#elif defined(__aarch64__)
printf( "Architektura: ARM64\n" );
#elif defined(__arm__)
printf( "Architektura: ARM\n" );
#else
printf( "Architektura: Nieznana\n" );
#endif
#ifdef __has_include
# if __has_include(<threads.h>)
printf( "[✓] <threads.h> dostępny\n" );
# else
printf( "[ ] <threads.h> brak\n" );
# endif
#else
printf( "[-] __has_include niewspierany\n" );
#endif
#ifdef __has_c_attribute
# if __has_c_attribute(maybe_unused)
printf( "[✓] [[maybe_unused]] (C23) wspierany\n" );
# else
printf( "[ ] [[maybe_unused]] (C23) niewspierany\n" );
# endif
#else
printf( "[-] __has_c_attribute niewspierany\n" );
#endif
#ifdef __cplusplus
# ifdef __has_cpp_attribute
# if __has_cpp_attribute(nodiscard)
printf( "[✓] [[nodiscard]] (C++) wspierany\n" );
# else
printf( "[ ] [[nodiscard]] (C++) niewspierany\n" );
# endif
# else
printf( "[-] __has_cpp_attribute niewspierany\n" );
# endif
#endif
printf( "============================================\n\n" );
}
Przykładowy plik main.c / main.cpp
#include "common_features.h"
int main( void )
{
print_language_info();
}
Przykład użycia na Linuxie
Przyznaję się - powyższy kod wygenerowała mi sztuczna inteligencja, testowałem go na Linuxie.
Szybkość C vs szybkość C++
Odniosę się do plotki, że
C jest rzekomo szybsze niż
C++.
Szybszy jest kod, który lepiej działa na sprzęcie (jest lepiej skompilowany, lepiej zoptymalizowany, lepiej pasujący pod konkretną architekturę). Jak dla wygody stosujemy wyższy poziom abstrakcji (np. polimorfizm), to kod działa wolniej. Są przypadki kiedy kod w
C++ jest szybszy, bo kompilator lepiej optymalizuje (albo my stosujemy więcej możliwości języka aby kod działał szybciej).
Pamiętajmy też, że nawet pisząc w szybkim języku programowania w dalszym ciągu można "znoobić" i napisać niewydajny kod.
Paradygmaty programowania w C i C++
Paradygmat programowania określa sposób organizacji i strukturyzacji kodu, wpływając na jego czytelność, utrzymywalność i wydajność. Język
C23 wspiera wyłącznie paradygmat proceduralny, podczas gdy
C++23 jest językiem wieloparadygmatowym, oferując programowanie proceduralne, obiektowe, generyczne i elementy funkcjonalne.
Podsumowanie
W niniejszym artykule zostały opisane główne różnice między językami
C a
C++ bazując na najnowszych (na moment pisania artykułu) standardach tychże języków. Aby nikt nie powiedział, że
C++ to "C z klasami", oraz aby nikt już nie pisał w stylu "C+", a artykuł ten będzie ściągą różnic wraz z informacjami co i gdzie doczytać przechodząc z używania jednego z tych języków na drugi.
Artykuł pokazuje, że
C++ oferuje szablony, wyjątki, korutyny i inne zaawansowane mechanizmy, które wykraczają poza „C z klasami”. Programiści
C powinni porzucić styl „C+” i sięgnąć po możliwości
C++, kierując się nie tylko możliwościami języka, składnią i biblioteką standardową ale sięgnąć po spis najlepszych praktyk programowania w
C++:
C++ Core Guidelines. Użytkownicy
C++ wracający do
C docenią nowości
C23, jak
nullptr
czy
constexpr
, ale muszą pamiętać o jego proceduralnych ograniczeniach. Wybór języka zależy od projektu –
C dla prostoty,
C++ dla elastyczności.
Bibliografia