Panel użytkownika
Nazwa użytkownika:
Hasło:
Nie masz jeszcze konta?
Autor: Grzegorz 'baziorek' Bazior
Inne artykuły

Porównanie C++ i C - różnice w składni i funkcjonalnościach

[artykuł] Zwięzłe porównanie języków C i C++, ukazujące różnice w składni, funkcjach i podejściu do programowania. Idealne dla programistów C poznających C++.

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:
  • Pokazanie dogłębnych różnic między tymi językami programowania
  • Uświadomić programistów C piszących w C++, że nie należy pisać w "C+", tylko korzystać z pełni funkcjonalności języka C++
  • Zamierzam uargumentować osobom, które C++ określają jako "C z klasami" - że nie powinni tak mówić dopóki nie cofną się do lat 80-tych ubiegłego wieku. Wiemy jednak, że się nie cofną, więc niech lepiej nawet nie myślą, żeby tak mówić.
  • Ktoś kto kilka lat temu skończył studia, gdzie poznał dobrze C, a potem używał C++ latami będąc pewnym, że nadal zna C trwa w błędzie: język ten się zmienił na tyle, że warto przeczytać ten artykuł (sam się o tym przekonałem pisząc go).
  • Jeśli kiedyś znałeś obydwa opisywane języki, ale kilka lat masz styczność tylko z jednym z nich, a minęło kilka lat, to podsumowanie będzie przydatne.
  • Aby student po semestrze nauki z C (nawet jak się dobrze nauczył C) nie zakładał, że nie musi pracować na semestrze C++.
  • Jeśli nauczyciel akademicki chce zrobić na zajęciach z C++ przejście z języka C to ma materiał referencyjny.
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

  • Krótka historia języków C i C++, oraz jak się kształtują standardy.
  • Różnice składniowe przy funkcjonalnościach dostępnych w obydwu językach
  • Funkcjonalności dostępne w obydwu językach, ale o znacząco się różniące między językami np. struktury
  • Kompatybilność binarna między językami, oraz manglowanie nazw funkcji podczas kompilacji
  • Funkcjonalności C++, których nie ma w C
  • Funkcjonalności C, których nie ma w C++
  • Biblioteka standardowa - różnice (bez wchodzenia w szczegóły)
  • Dynamiczna alokacja pamięci
  • Różnice na pograniczu biblioteki standardowej i języka
  • Bonus - kod który rozpoznaje czy jest kompilowany przez kompilator C czy C++, zwracający informacje o wersji standardu, systemie, dostępnych nagłówkach
  • Szybkość C vs szybkość C++ - odkręcanie mitu
  • Porównanie paradygmatów programowania C i C++
  • Podsumowanie i co dalej
  • Bibliografia
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

  • 1972 – powstanie języka C
  • 1978 – książka „The C Programming Language” (wydanie polskie "Język C") – nieoficjalny standard, zwany C78.
  • 1989 – ANSI C (C89), przyjęty w 1990 jako ISO C (C90), wprowadzono m.in.
    const
     i
    volatile
     z C++.
  • 1999 – C99, dodano z C++:
    inline
    , komentarze
    //
    ,
    long long int
    , typ
    _Bool
     (z makrami
    bool
    ,
    true
    ,
    false
    ), anonimowe argumenty funkcji.
  • 2011 – C11, współpraca z C++ w celu zachowania zgodności: wielowątkowość, operacje atomowe, Unicode.
  • 2018 – C17 (nieoficjalnie zwany też C18) – brak znaczących zmian językowych, naprawa defektów z C11
  • 2023 – C23, dodano z C++:
    bool
    jako typ (nie makro), pusta lista argumentów funkcji oznacza brak argumentów (jak w C++), atrybuty, słówko
    auto
     jako automatyczna dedukcja typów,
    constexpr
    ,
    static_assert
    ,
    nullptr
    , zerowanie tablic za pomocą
    { }
    .

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++

Oficjalne logo C++
Oficjalne logo C++
  • 1979 – rozpoczęcie prac nad "C z klasami".
  • 1985 – pierwsze wydanie książki "The C++ Programming Language", będąca opisem języka C++.
  • 1989 – C++ 2.0 (zaktualizowana książka w 1991).
  • 1998 – C++98 (ISO/IEC 14882:1998), zwany C++98.
  • 2003 – C++03.
  • 2011 – C++11, od tego momentu nowe standardy co 3 lata.
  • 2014, 2017, 2020, 2023 – kolejne standardy, odpowiednio C++14, C++17, C++20, C++23

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".

C C++
Tradycyjny "witaj świecie" przed C99):
C/C++
#include <stdio.h>

int main( void )
{
   
printf( "Witaj, świecie!\n" );
   
// alternatywnie do powyższego:
    // puts("Witaj, świecie!");
   
return 0;
}
Tradycyjne "witaj świecie" przed C++20:
C/C++
#include <iostream>

int main()
{
   
std::cout << "Witaj świecie programowania w C++" << std::endl;
}
W C od wersji C99:
C/C++
#include <stdio.h>

int main()
{
   
printf( "Witaj, świecie!\n" );
   
// alternatywnie do powyższego:
    // puts("Witaj, świecie!");
}
W C++ od wersji C++23:
C/C++
#include <print>

int main()
{
   
std::println( "Witaj świecie programowania w C++" );
}

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.

C23 C++23
Próba zdefiniowania dwóch funkcji o tej samej nazwie spowoduje błąd kompilacji:
C/C++
void func( int a ) { }
// void func(double a) {} // Błąd: redefinicja funkcji
Poniższe jest OK:
C/C++
void func( int a ) { }
void func( double a ) { }
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.
C/C++
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.
C/C++
#include <print>

void greet( const char * name = "Gościu" )
{
   
std::println( "Witaj, {}!", name );
}

int main()
{
   
greet( "Ala" );
   
greet(); // Użyje wartości domyślnej
}

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.
C23 C++23
C/C++
#include <stdio.h>

// auto f() { return 4; } - w C23 nie działa

int main() {
   
auto i = 4; // i jest typu int
   
auto d = 3.14; // d jest typu double
   
auto f = 2.71f; // f jest typu float
   
printf( "%d %lf %f\n", i, d, f );
}
C/C++
#include <iostream>

auto f() { return 4ULL; } // typem zwracanym jest unsigned long long int

int main() {
   
auto i = 4; // i jest typu int
   
auto d = 3.14; // d jest typu double
   
auto f = 2.71f; // f jest typu float
   
std::cout << i << ' ' << d << ' ' << f << ' ' <<::f() << std::endl;
}
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.:
C/C++
auto int z = 10; // auto jako specyfikator klasy przechowywania, z jest int
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.

C23 C++23
C/C++
#include <stdio.h>
struct Point
{
   
int x, y;
};
int main()
{
   
struct Point p = { 1, 2 };
   
int x = p.x; // Ręczny dostęp do pól
   
int y = p.y;
   
printf( "x: %d, y: %d\n", x, y ); // Wyświetli: x: 1, y: 2
}
C/C++
#include <print>
struct Point
{
   
int x, y;
};
int main()
{
   
Point p { 1, 2 };
   
auto[ x, y ] = p; // Structured binding: x i y to int
   
std::println( "x: {}, y: {}", x, y ); // Wyświetli: x: 1, y: 2
   
   
int a[ 2 ] = { 3, 4 };
   
auto[ a1, a2 ] = a; // Structured binding: a1 i a2 to int
   
std::println( "a1: {}, a2: {}", a1, a2 ); // Wyświetli: a1: 3, a2: 4
}
W C23 konieczność ręcznego przypisywania każdego pola struktury zwiększa ilość kodu i ryzyko błędów. W C++23 structured binding pozwala na bardziej zwięzły i czytelny kod, szczególnie przy pracy z wieloma składowymi.
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++:
C/C++
int a = 5;
int & ref = a;
ref = 6;
std::cout << a << " vs " << ref << std::endl; // zostanie wyświetlone 6 vs 6
// int &ref2; <- nie można stworzyć tzw. "wiszącej" (ang. dangling) referencji
Poza l-referencjami istnieją również prawe-referencje (ang. r-reference), które mogą wskazywać na wartości chwilowe (tzw. prawe wartości) :
C/C++
int f() { return 4; }

int main()
{
   
int && rref = f();
   
int i = 5;
   
int && rref2 = std::move( i ); // std::move rzutuje na prawą referencję
}
Mamy również dostępną uniwersalną referencję, która może wskazać na wszystko (również wartości chwilowe):
C/C++
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.
C/C++
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ą):
C/C++
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:
C/C++
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):
C/C++
#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; // wyliczone w trakcie kompilacji
   
std::cout << fibonacci( 30 ) << std::endl; // wyliczone w trakcie wykonywania
}
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:
C/C++
#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 ); // zmieniliśmy
   
   
constexpr const int ccint = 16;
   
ptr =( int * ) & ccint;
   
* ptr = 64; // zmiana nie powinna się udać
   
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
C/C++
#include <iostream>
#include <cstring>  
// std::strlen

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 ) // od C++17
       
 std::cout << text << std::endl;
   
   
switch( auto textLength = std::strlen( text ) ) // od C++17
   
{
   
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.
C/C++
#include <print>
#include <cstdint>  
// std::int32_t

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:
C/C++
#include <print>

consteval int square( int n )
{
   
return n * n;
}
constexpr int compute( int n )
{
   
if consteval
    {
       
return square( n ); // Wywołanie w czasie kompilacji
       
   
} else
   
{
       
return n * n; // Wywołanie w czasie wykonania
   
}
}

int main()
{
   
constexpr int compile_time_result = compute( 5 ); // Używa square(5), wynik: 25
   
int x = 5;
   
int run_time_result = compute( x ); // Używa n * n, wynik: 25
   
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:
C/C++
#include <iostream>

int main() {
   
const int arr[ ] { 1, 2, 4, 8, 16 };
   
for( auto element: arr ) // legalne od C++11
       
 std::cout << element << ", ";
   
   
std::cout << std::endl;
   
   
for( int counter { }; auto element: arr ) // legalne od C++17
       
 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:
C23 C++23
C/C++
#include <stdio.h>
#include <stddef.h>  
// dla NULL (opcjonalnie)
int main() {
   
int * ptr1 = NULL; // klasyczny wskaźnik pusty
   
int * ptr2 = nullptr; // od C23
   
printf( "%p %p\n",( void * ) ptr1,( void * ) ptr2 );
}
C/C++
#include <print>

void func( int n ) { std::println( "int: {}", n ); }
void func( long long n ) { std::println( "long long: {}", n ); }
void func( char * s ) { std::println( "char*: {}", s ? s: "null" ); }

int main() {
   
// func(NULL);    // błąd: nie wiadomo, którą funkcję wybrać
   
func( nullptr ); // poprawnie: wybiera wersję z char*
}
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).
C C++
W C++ statyczne asercje są realizowane za pomocą słowa kluczowego
static_assert
, wprowadzonego w C++11.
W C, od wersji C11, wprowadzono
_Static_assert
, a w C23 wprowadzono również
static_assert
.
C/C++
static_assert( sizeof( int ) == 4, "int nie ma rozmiaru 4 bajtów" ); // od C++11
static_assert( sizeof( int ) == 4 ); // od C++17
static_assert( sizeof( int ) == 4, "int nie ma rozmiaru 4 bajtów" );

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:
C/C++
#include <stdio.h>
int main()
{
   
int i = 4;
   
float * fptr =( float * ) & i; // C-style cast
   
printf( "%f\n", * fptr ); // Niezdefiniowane zachowanie
}
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.
operator rzutowania opis przykład
static_cast
służy do bezpiecznych konwersji między pokrewnymi typami, takimi jak liczby całkowite i zmiennoprzecinkowe, lub w hierarchii klas (np. z typu pochodnego na bazowy). Jest sprawdzany w czasie kompilacji, co zapobiega niezdefiniowanemu zachowaniu.
C/C++
#include <print>
int main()
{
   
double d = 3.14;
   
int i = static_cast( d ); // Konwersja double na int
   
std::println( "{}", i ); // Wyświetli: 3
}
dynamic_cast
jest używany do bezpiecznych konwersji w hierarchii klas z polimorfizmem (o tym później w artykule), głównie w dół hierarchii (z typu bazowego na pochodny). Sprawdza w czasie wykonywania, czy rzutowanie jest możliwe, jeśli nie to zwraca
nullptr
 (dla wskaźników) lub rzuca wyjątek
std::bad_cast
 (dla referencji). Wymaga, aby klasa bazowa miała przynajmniej jedną metodę wirtualną.
C/C++
#include <print>
struct Base
{
   
virtual void func() { }
}
;
struct Derived
    : Base
{ };
int main() {
   
Base * b = new Derived;
   
Derived * d = dynamic_cast < Derived * >( b ); // Bezpieczne rzutowanie
   
if( d ) std::println( "Rzutowanie udane" );
   
else std::println( "Rzutowanie nieudane" );
   
   
delete b;
}
const_cast
służy do usuwania lub dodawania kwalifikatorów
const
 lub
volatile
. Jest to jedyny sposób w C++ na zmianę constness, ale należy go używać ostrożnie, ponieważ modyfikacja obiektu zadeklarowanego jako
const
 prowadzi do niezdefiniowanego zachowania
C/C++
#include <print>
void modify( int * ptr ) { * ptr = 10; }
int main() {
   
const int i = 5;
   
int * ptr = const_cast < int * >( & i ); // Usunięcie const
   
modify( ptr ); // Niezdefiniowane zachowanie
   
std::println( "{}", i );
}
reinterpret_cast
służy do reinterpretacji wzorca bitowego jednego typu jako inny, np. konwersji między wskaźnikami różnych typów. Jest to najbardziej niebezpieczne rzutowanie, ponieważ nie sprawdza zgodności typów i może prowadzić do niezdefiniowanego zachowania, jeśli typy nie są kompatybilne
C/C++
#include <print>
int main() {
   
int i = 42;
   
float * fptr = reinterpret_cast < float * >( & i ); // Reinterpretacja bitów
   
std::println( "{}", * fptr ); // Niezdefiniowane zachowanie
}
std::bit_cast
w nagłówku
#include <bit>
)
funkcja dostępna od C++20. Zapewnia bezpieczną reinterpretację bitową między typami o tym samym rozmiarze i trywialnie kopiowalnymi. W przeciwieństwie do
reinterpret_cast
,
std::bit_cast
 jest wykonywany w czasie kompilacji, jeśli to możliwe, i gwarantuje poprawność dla typów spełniających wymagania.
C/C++
#include <print>
int main()
{
   
float f = 3.14f; auto i = std::bit_cast( f ); // Bezpieczna reinterpretacja bitów
   
std::println( "{}", i ); // Wyświetli reprezentację bitową liczby float
}
( type ) expression
lub
type( expression )
rzutowanie w stylu języka C w dwóch odmianach. Jednakże zaleca się używanie dedykowanych operatorów rzutowania zamiast C-style cast, ponieważ są one bardziej czytelne, bezpieczne i pozwalają kompilatorowi lepiej wykrywać błędy

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.
C23 C++23
C/C++
#include <stdio.h>
int main()
{
   
int i = 42;
   
void * vptr = & i; // Niejawna konwersja int* na void*
   
float * fptr = vptr; // Niejawna konwersja void* na float*
   
printf( "%f\n", * fptr ); // Niezdefiniowane zachowanie
}
C/C++
#include <print>
int main()
{
   
int i = 42;
   
void * vptr = & i; // Niejawna konwersja int* na void*
    // float* fptr = vptr; // Błąd kompilacji
   
float * fptr = static_cast < float * >( vptr ); // Jawne rzutowanie
   
std::println( "{}", * fptr ); // Niezdefiniowane zachowanie
}
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.
C C++
W C23 używa się funkcji takich jak
sprintf
 do konwersji na tekst oraz
atoi
,
atof
 lub
strtol
 do parsowania tekstu. Te funkcje są proste, ale mają ograniczenia, np.
atoi
 nie rozróżnia między wartością
0
 a niepowodzeniem parsowania, co może prowadzić do błędów.
W C++23 używa się funkcji
std::to_string
 do konwersji na tekst oraz
std::stoi
,
std::stof
 itp. do parsowania tekstu. Te funkcje są bezpieczniejsze, ponieważ rzucają wyjątki (np.
std::invalid_argument
 lub
std::out_of_range
) w przypadku niepowodzenia, co pozwala na lepszą obsługę błędów.
C/C++
#include <stdio.h>
#include <stdlib.h>
int main()
{
   
int i = 42;
   
char buffer[ 32 ];
   
sprintf( buffer, "%d", i ); // Konwersja int na tekst
   
printf( "Tekst: %s\n", buffer );
   
const char * str = "123";
   
int num = atoi( str ); // Parsowanie tekstu na int
   
printf( "Liczba: %d\n", num ); // Wyświetli: 123
   
const char * invalid = "abc";
   
num = atoi( invalid ); // Nie sygnalizuje błędu, zwraca 0
   
printf( "Niepoprawny tekst: %d\n", num ); // Wyświetli: 0
}
C/C++
#include <print>
#include <string>  
// std::to_string
int main()
{
   
int i = 42;
   
std::string str = std::to_string( i ); // Konwersja int na tekst
   
std::println( "Tekst: {}", str );
   
try
   
{
       
std::string input = "123";
       
int num = std::stoi( input ); // Parsowanie tekstu na int
       
std::println( "Liczba: {}", num ); // Wyświetli: 123
       
input = "abc";
       
num = std::stoi( input ); // Rzuca wyjątek
   
}
   
catch( const std::invalid_argument & e )
   
{
       
std::println( "Błąd parsowania: {}", e.what() );
   
}
}

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
.
C23 C++23
C/C++
#include <stdio.h>

int main() {
   
const int x = 5;
   
typeof( x ) a = 10; // a ma typ const int
   
typeof_unqual( x ) b = 20; // b ma typ int (bez const)
   
   
double d = 3.14;
   
typeof( x + d ) sum = x + d; // sum ma typ double
   
   
typedef typeof( sum ) sum_type;
   
sum_type sum2 = sum + 1.0;
   
   
printf( "sum2: %.2f\n", sum2 ); // Wyświetli: sum2: 19.14
   
printf( "a: %d, b: %d\n", a, b ); // a: 10, b: 20
}
C/C++
#include <numbers>
#include <print>

auto add( int i, double d )->decltype( i + d )
{
   
return i + d;
}

int main() {
   
int x = 5;
   
decltype( x ) y = 10; // y jest typu int
   
   
const double PI = std::numbers::pi;
   
decltype( PI ) E = std::numbers::e; // E jest const double
   
   
decltype( add( x, PI ) ) sum = add( x, PI ); // typ wyniku funkcji add
   
   
typedef decltype( sum ) SumType;
   
SumType sum2 = sum + E;
   
   
decltype( auto ) sum3 = sum2; // dokładnie taki typ jak sum2 (łącznie z const/ref)
   
std::println( "sum3: {}", sum3 ); // np. sum3: 19.14
}
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 };
.

C C++
C/C++
#include <stdio.h>
#include <stdint.h>  
// uint8_t

enum Settings: uint8_t
{
   
DISABLE_EVERYTHING = 0x00,
   
ENABLE_A = 0x01,
   
ENABLE_B = 0x02,
   
ENABLE_C = 0x04,
   
ENABLE_D = 0x08,
   
ENABLE_E = 0x10,
   
ENABLE_F = 0x20,
   
ENABLE_G = 0x40,
   
ENABLE_H = 0x80,
   
ENABLE_ALL = ENABLE_A | ENABLE_B | ENABLE_C | ENABLE_D | ENABLE_E | ENABLE_F | ENABLE_G | ENABLE_H
};

int main()
{
   
enum Settings s = ENABLE_H;
   
printf( ">%d<\n",( int ) s );
}
C/C++
#include <print>
#include <cstdint>  
// std::uint8_t
#include <utility>  // std::to_underlying

enum Settings: std::uint8_t
{
   
DISABLE_EVERYTHING = 0x00,
   
ENABLE_A = 0x01,
   
ENABLE_B = 0x02,
   
ENABLE_C = 0x04,
   
ENABLE_D = 0x08,
   
ENABLE_E = 0x10,
   
ENABLE_F = 0x20,
   
ENABLE_G = 0x40,
   
ENABLE_H = 0x80,
   
ENABLE_ALL = ENABLE_A | ENABLE_B | ENABLE_C | ENABLE_D | ENABLE_E | ENABLE_F | ENABLE_G | ENABLE_H
};

int main()
{
   
Settings s = ENABLE_H;
   
std::println( ">{}<\n", std::to_underlying( s ) );
   
   
typedef std::underlying_type_t < Settings > SettingsUnderlyingType;
}
W C enumeratory są globalne, co może prowadzić do konfliktów nazw. Deklaracja zmiennej wymaga powtórzenia słowa
enum
, chyba że użyto
typedef
, np.
typedef enum Settings Settings;
.
W C++ enumeratory zwykłych
enum
 są globalne, ale nazwa typu nie wymaga słowa
enum
 przy deklaracji zmiennej.
Brak odpowiednika
std::to_underlying
 w C. Wartość enumeratora jest niejawnie konwertowalna na typ bazowy.
W C++ funkcja
std::to_underlying
 (od C++23) konwertuje enumerator na typ bazowy. Typ bazowy można uzyskać za pomocą
std::underlying_type_t
 (od C++11).

enum class w C++11
enum class
, w C++11 zostało wprowadzone aby rozwiązać pewne problemy związane z typami wyliczeniowymi:
  • Zapobiegają wyciekaniu enumeratorów do przestrzeni globalnej, wymagając kwalifikacji (np.
    Color::RED
    ).
  • Blokują niejawną konwersję na liczby całkowite, zwiększając bezpieczeństwo typów.
  • Uniemożliwiają porównywanie enumeratorów z różnych typów wyliczeniowych.
  • Pozwalają określić typ bazowy, podobnie jak zwykłe
    enum
    .
  • Możliwe jest zadeklarowanie typu wyliczeniowego

Przykład w C++23:
C/C++
#include <print>
#include <cstdint>  
// std::uint8_t
enum class Color
    : std::uint8_t
{
   
RED, GREEN, BLUE
};
int main()
{
   
Color c = Color::RED;
   
// int i = c; // Błąd: niejawna konwersja niedozwolona
   
std::println( "{}", std::to_underlying( c ) ); // Wyświetli: 0
}

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:
C/C++
#include <stdio.h>
#include <limits.h>
#include <stdint.h>

enum Big
{
   
A = - 1,
   
B = 100000000000ULL, // 100 miliardow
   
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++.

C23 C++23
C/C++
#include <stdio.h>
#include <stdbool.h>
int main()
{
   
bool flag = true;
   
if( flag )
       
 printf( "%d\n", flag ); // Wyświetli: 1
    // alternatywnie:
   
printf( "%s\n", flag ? "true": "false" ); // Wyświetli: true
}
C/C++
#include <print>
int main()
{
   
bool flag = true;
   
if( flag )
       
 std::println( "{}", flag ); // Wyświetli: true
   
}
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.
C23 C++23
C/C++
#include <stdio.h>
struct Point
{
   
int x, y;
};
int main()
{
   
struct Point p = { 1, 2 }; // Inicjalizacja klamrowa
   
printf( "(%d, %d)\n", p.x, p.y ); // Wyswietli: (1, 2)
   
struct Point p2 = {.x = 3,.y = 4 }; // Inicjalizacja klamrowa
   
printf( "(%d, %d)\n", p2.x, p2.y ); // Wyswietli: (3, 4)
   
    // int i{5}; // Błąd kompilacji - w C tak nie wolno
   
int j = { 5.2 }; // Błąd: niejawna konwersja niedozwolona
    // int k(4); // Błąd kompilacji - w C tak nie wolno
   
printf( "%d\n", j );
   
   
// int arr[]{1,2,3}; // Błąd kompilacji - w C tak nie wolno
   
int arr2[ ] = { 1, 2, 3 };
   
int arr3[ 2 ] = { }; // zerowanie tablicy
   
printf( "[%d, %d, %d]\n", arr2[ 0 ], arr2[ 1 ], arr2[ 2 ] ); // Wyświetli: [1, 2, 3]
}
C/C++
#include <print>
struct Point
{
   
int x, y;
};
int main()
{
   
Point p = { 1, 2 }; // Inicjalizacja klamrowa
   
std::println( "({}, {})", p.x, p.y ); // Wyświetli: (1, 2)
   
Point p2 = {.x = 3,.y = 4 }; // Inicjalizacja klamrowa
   
std::println( "({}, {})", p2.x, p2.y ); // Wyświetli: (3, 4)
   
   
int i { 5 }; // Ok, i ma wartość 5
    // int j = {5.2}; // B??d: obcinanie typu double do int
   
int k( 4 ); // OK, k ma wartość 4
   
int j( 3.3 ); // OK, nie ma błędu kompilacji, mimo iż obcinamy z double do int
   
std::println( "{} {} {}", i, k, j );
   
   
int arr[ ] { 1, 2, 3 }; // OK w C++
   
int arr2[ ] = { 1, 2, 3 }; // dalej OK
   
int arr3[ 2 ] = { }; // zerowanie tablicy
   
std::println( "[{}, {}, {}]", arr2[ 0 ], arr2[ 1 ], arr2[ 2 ] ); // Wyświetli: [1, 2, 3]
   
int h { }; // zerowanie zmiennej
   
int m = { }; // zerowanie zmiennej
   
std::println( "{} {}", h, m );
}
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):
C/C++
inline int square( int n )
{
   
return n * n;
}
int main()
{
   
printf( "%d\n", square( 5 ) ); // Wyświetli: 25
}

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.
C23 C++23
C/C++
#include <stdio.h>
[[ nodiscard ] ] int compute_value( int x )
{
   
return x * 2;
}
[[ deprecated( "Użyj compute_value zamiast old_function" ) ] ] int old_function( int x )
{
   
return x + 1;
}
int main()
{
   
int result = compute_value( 5 );
   
printf( "Wynik: %d\n", result ); // Wyświetli: Wynik: 10
   
old_function( 5 ); // Może wygenerować ostrzeżenie o użyciu funkcji oznaczonej jako deprecated
}
C/C++
#include <print>
[[ nodiscard ] ] int compute_value( int x )
{
   
return x * 2;
}
[[ deprecated( "Użyj compute_value zamiast old_function" ) ] ] int old_function( int x )
{
   
return x + 1;
}
int main()
{
   
if( int result = compute_value( 5 );[[ likely ] ] result > 0 )
   
{
       
std::println( "Wynik: {}", result ); // Wyświetli: Wynik: 10
   
}
   
old_function( 5 ); // Może wygenerować ostrzeżenie o użyciu funkcji oznaczonej jako deprecated
}

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.
Atrybut C23 C++ (Standard) Opis
[[ noreturn ] ]
Tak C++11 Wskazuje, że funkcja nigdy nie zwraca sterowania (np.
exit
,
abort
).
[[ deprecated ] ]
Tak C++14 Oznacza funkcję lub zmienną jako przestarzałą, generując ostrzeżenia przy użyciu.
[[ nodiscard ] ]
Tak C++17 Wskazuje, że wartość zwracana przez funkcję nie powinna być ignorowana.
[[ maybe_unused ] ]
Tak C++17 Zapobiega ostrzeżeniom o nieużywanych zmiennych lub parametrach.
[[ fallthrough ] ]
Tak C++17 Wskazuje celowe pominięcie
break
 w
switch
, zapobiegając ostrzeżeniom.
[[ likely ] ]
Nie
C++20 Sugeruje kompilatorowi, że dana ścieżka kodu jest bardziej prawdopodobna, wspierając optymalizacje.
[[ unlikely ] ]
Nie
C++20 Sugeruje kompilatorowi, że dana ścieżka kodu jest mniej prawdopodobna, wspierając optymalizacje.
[[ no_unique_address ] ]
Nie
C++20 Pozwala na optymalizację pamięci dla pustych składowych klas.
[[ assume ] ]
Nie
C++23 Informuje kompilator o założeniach dotyczących wartości wyrażeń, wspierając agresywne optymalizacje.
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:
C/C++
#include <stdio.h>
#include <string.h>  
// strcpy

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 ); // Wyświetli: Pracownik: Jan Kowalski, ID: 1234, Wynagrodzenie: 5000.75
}
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.
C23 C++23
C/C++
#include <stdio.h>
#include <stdint.h>
#include <string.h>

union U
{
   
int32_t i;
   
float f;
};

int main()
{
   
union U u; // niezainicjalizowana
   
printf( "U u: i=%d, f=%lf\n", u.i, u.f ); // i=(śmieci) (śmieci)
   
u.f = 3.14; // nadpisujemy zawartość uni
   
printf( "U u: i=%d, f=%lf\n", u.i, u.f ); // i=(inna wartość) 3.14
   
   
union U u2 = { }; // wyzerowanie całej uni
   
printf( "U u2: i=%d, f=%lf\n", u2.i, u2.f ); // i=0 f=0
   
   
union U u3 = {.f = 2.71 }; // zainicjalizowanie jednej skladowej
   
printf( "U u3: i=%d, f=%lf\n", u3.i, u3.f ); // i=(inna wartość) f=2.71
}
C/C++
#include <print>
#include <cstdint>
#include <stdfloat>  
// C++23

union U1
{
   
std::int32_t i;
   
std::float32_t f;
};

union U2
{
   
std::int32_t i;
   
std::float32_t f;
   
   
U2()
        :
i
    { - 1 }
    { }
   
void func() { i = f; }
}
;

union U3
{
   
std::int32_t i;
   
std::string s;
};
union U4
{
   
std::int32_t i;
   
std::string s;
   
U4() { }
   
~U4() { }
}
;

int main()
{
   
U1 u; // niezainicjalizowana
   
std::println( "U1 u: i={}, f={}", u.i, u.f ); // i=(śmieci) (śmieci)
   
u.f = 3.14;
   
std::println( "U1 u: i={}, f={}", u.i, u.f ); // i=(inna wartość) 3.14
   
   
U1 u2 = { }; // wyzerowanie całej uni
   
std::println( "U1 u2: i={}, f={}", u2.i, u2.f ); // i=0 f=0
   
   
U1 u3 = {.f = 2.71 }; // zainicjalizowanie jednej skladowej
   
std::println( "U2 u3: i={}, f={}", u3.i, u3.f ); // i=(inna wartość) f=2.71
   
   
U2 u4; // domyślny konstruktor
   
std::println( "U2 u3: i={}, f={}", u4.i, u4.f ); // i=-1 f=(inna wartość)
   
u4.func();
   
   
// U3 u5; // błąd kompilacji, std::string to typ złożony
   
U4 u6; // jest konstruktor i destruktor
}
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)

C/C++
#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)

C/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)

C/C++
#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.
C23 C++23
C/C++
#include <stdio.h>
// Brak przestrzeni nazw, konflikty nazw rozwiązuje się ręcznie
void moo()
{
   
printf( "Muuu! (Ziemska krowa)\n" );
}
void space_moo()
{
   
printf( "Muuu! (Kosmiczna krowa)\n" );
}
int main()
{
   
moo(); // Wyświetli:    // Muuu! (Ziemska krowa)
   
space_moo(); // Wyświetli:    // Muuu! (Kosmiczna krowa)
}
C/C++
#include <print>
namespace Earth
{
   
void moo()
   
{
       
std::println( "Muuu! (Ziemska krowa)" );
   
}
}
// namespace Earth
namespace Space
{
   
void moo()
   
{
       
std::println( "Muuu! (Kosmiczna krowa)" );
   
}
}
// namespace Space
namespace DefaultPlanet = Earth;
int main()
{
   
Earth::moo(); // Wyświetli:    // Muuu! (Ziemska krowa)
   
Space::moo(); // Wyświetli:    // Muuu! (Kosmiczna krowa)
   
DefaultPlanet::moo(); // Wyświetli:    // Muuu! (Ziemska krowa)
}
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
C/C++
export module helloworld; // module declaration
import < iostream >; // import declaration

export void hello() // export declaration
{
   
std::cout << "Hello world!\n";
}
Plik main.cpp
C/C++
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 na tyle szerokie zagadnienie, że tylko o nim wspominam. Należy doczytać o nim w dobrej książce do programowania w C++. Jak ktoś nie chce w książce, to polecam kurs programowania obiektowego w C++ w Cpp0x.pl.
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.
C23 C++23
C/C++
#include <stdio.h>
struct Cow
{
   
double milk;
};
void milk_cow( struct Cow * cow )
{
   
cow->milk += 5.0;
   
printf( "Krowa dała %.1f litrów mleka\n", cow->milk );
}
int main()
{
   
struct Cow cow = { 0.0 };
   
milk_cow( & cow ); // Wyświetli: Krowa dała 5.0 litrów mleka
}
C/C++
#include <print>
class Cow
{
public:
   
Cow()
        :
milk_( 0.0 )
   
{ }
   
void milk()
   
{
       
milk_ += 5.0;
       
std::println( "Krowa dała {} litrów mleka", milk_ );
   
}
   
Cow operator +( const Cow & other ) const // Przeciążanie operatora +
   
{
       
Cow result;
       
result.milk_ = milk_ + other.milk_;
       
return result;
   
}
private:
   
double milk_;
};
int main()
{
   
Cow cow1, cow2;
   
cow1.milk(); // Wyświetli: Krowa dała 5 litrów mleka
   
Cow superCow = cow1 + cow2; // Użycie przeciążonego operatora
   
superCow.milk(); // Wyświetli: Krowa dała 10 litrów mleka
}
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++:
C/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 ) // punktowy "kwadrat"
   
};
   
   
for( int i = 0; i < N; ++i )
   
{
       
std::println( "Shape #{}:", i + 1 );
       
drawShape( * shapes[ i ], /*stageHeigh=*/ 9, /*stageWidth=*/ 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:
C/C++
#include <cstring>  // strlen

class Book
{
   
char * text;
public:
   
Book()
        :
text
    { nullptr }
    { }
   
/// kopiowanie:
   
Book( const Book & book )
        :
text
    { book.text ? new char[ strlen( book.text ) + 1 ]
            :
nullptr }
    {
       
if( book.text )
           
 strcpy( text, book.text );
       
   
}
   
/// przenoszenie:
   
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ł.
C23 C++23
C/C++
#include <stdio.h>

int milk_cow( int amount )
{
   
if( amount < 0 )
   
{
       
printf( "Błąd: Ujemna ilość mleka!\n" );
       
return - 1;
   
}
   
printf( "Udojono %d litrów mleka\n", amount );
   
return 0;
}

int main()
{
   
if( milk_cow( - 5 ) == - 1 )
   
{
       
printf( "Nie udało się udoić krowy\n" );
   
}
   
// Wyświetli:
    // Błąd: Ujemna ilość mleka!
    // Nie udało się udoić krowy
}
C/C++
#include <print>
#include <stdexcept>

void milk_cow( int amount )
{
   
if( amount < 0 )
   
{
       
throw std::invalid_argument( "Ujemna ilość mleka!" );
   
}
   
std::println( "Udojono {} litrów mleka", amount );
}

int main()
{
   
try
   
{
       
milk_cow( - 5 );
   
}
   
catch( const std::invalid_argument & e )
   
{
       
std::println( "Błąd: {}", e.what() );
   
}
   
// Wyświetli:
    // Błąd: Ujemna ilość mleka!
}
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:
  • funkcji
  • klas
  • aliasów typów (alias templates)
  • stałych (constexpr templates)

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.
C23 C++23
C/C++
#include <stdio.h>

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

double add_double( double a, double b )
{
   
return a + b;
}

int main()
{
   
printf( "Suma int: %d\n", add_int( 2, 3 ) ); // Suma int: 5
   
printf( "Suma double: %.1f\n", add_double( 2.5, 3.5 ) ); // Suma double: 6.0
}
C/C++
#include <print>

template < typename T >
T add( T a, T b )
{
   
return a + b;
}

int main()
{
   
std::println( "Suma int: {}", add( 2, 3 ) ); // Suma int: 5
   
std::println( "Suma double: {}", add( 2.5, 3.5 ) ); // Suma double: 6
}
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.
C23 C++23
C/C++
#include <stdio.h>

void apply( int x, void( * func )( int ) )
{
   
func( x );
}

void moo( int count )
{
   
for( int i = 0; i < count; i++ )
   
{
       
printf( "Muuu!\n" );
   
}
}

int main()
{
   
apply( 3, moo );
   
// Wyświetli:
    // Muuu!
    // Muuu!
    // Muuu!
}
C/C++
#include <print>

void apply( int x, auto && func )
{
   
func( x );
}

int main()
{
   
apply( 3,[ ]( int count )
   
{
       
for( int i = 0; i < count; i++ )
       
{
           
std::println( "Muuu!" );
       
}
    }
);
   
// Wyświetli:
    // Muuu!
    // Muuu!
    // Muuu!
}
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.

C23 C++23
C/C++
#include <stdio.h>

#define SQUARE(n) ((n) * (n))

int main()
{
   
printf( "Kwadrat: %d\n", SQUARE( 5 ) ); // Wyświetli: Kwadrat: 25
}
C/C++
#include <print>

consteval int square( int n )
{
   
return n * n;
}

int main()
{
   
constexpr int result = square( 5 );
   
std::println( "Kwadrat: {}", result ); // Wyświetli: Kwadrat: 25
}
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).

C23 C++23
C/C++
#include <stdio.h>

int get_value()
{
   
printf( "inicjalizacja\n" );
   
return 42;
}

int x = get_value(); // brak wymuszenia inicjalizacji w czasie kompilacji

int main()
{
   
printf( "x = %d\n", x );
}
C/C++
#include <print>

consteval int get_value()
{
   
return 42;
}

constinit int x = get_value(); // wymuszenie inicjalizacji w czasie kompilacji

int main()
{
   
std::println( "x = {}", x ); // Wyświetli: x = 42
}
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
C/C++
#include <stdio.h>

int main( void )
{
   
int n = 10;
   
int arr[ n ];
   
int arr2[ n ] = { }; // od C23 wolno tak wyzerować tablicę
   
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.
C23 C++23
C/C++
#include <stdio.h>

// Osadzamy zawartość pliku "hello.txt" jako tablicę bajtów:
const unsigned char hello[ ] = {
   
#embed "hello.txt"
};
const unsigned long hello_len = sizeof( hello );

int main()
{
   
fwrite( hello, 1, hello_len, stdout ); // wypisuje zawartość hello.txt
}
Brak — w C++23 brak odpowiednika. Można posłużyć się konwersją pliku do tablicy bajtów np. narzędziem
xxd
:
xxd -i hello.txt > hello.h

i dołączyć tak:
#include "hello.h"

Ale to wymaga zewnętrznego przetworzenia pliku przed kompilacją.

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.
C/C++
#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 ] ); // Wyświetli: 5 7 9
   
}
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:
  • dokładnie tak samo jak w C np.
    #include <stdio.h>
     - niezalecane
  • w formie zmienionej, gdzie nagłówek jest poprzedzony literą
    c
    , a rozszerzenie pominięte. Przykładowo dla powyższego nagłówka będzie
    #include <cstdio>

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.

C23 C++23
Alokacja pojedynczego obiektu:
C/C++
#include <stdio.h>
#include <stdlib.h>

typedef struct {
   
int x, y;
} Point;

int main() {
   
Point * p = malloc( sizeof( Point ) );
   
if( p ) { // w razie niepowodzenia alokacji pamięci zwróci NULL
       
p->x = 3;
       
p->y = 4;
       
printf( "Punkt: (%d, %d)\n", p->x, p->y );
       
free( p );
   
}
   
p = NULL;
   
free( p ); // nic sie zlego nie stanie gdy NULL
}
Alokacja pojedynczego obiektu:
C/C++
#include <print>

struct Point {
   
int x, y;
   
Point( int x, int y )
        :
x( x )
       
, y( y )
   
{
       
std::println( "Konstruktor: ({}, {})", x, y );
   
}
   
~Point() {
       
std::println( "Destruktor: ({}, {})", x, y );
   
}
}
;

int main()
{
   
try {
       
Point * p = new Point( 3, 4 );
       
std::println( "Punkt: ({}, {})", p->x, p->y );
       
delete p;
       
p = nullptr;
       
delete p; // nic sie zlego nie stanie gdy nullptr
   
} // w razie niepowodzenia alokacji pamięci wyrzuci wyjątek std::bad_alloc
   
catch( const std::bad_alloc & e ) {
       
std::println( "! {}", e.what() );
   
}
   
   
Point * p = new( nothrow ) Point( 3, 4 ); // wersja new, która nie rzuca wyjątku
   
if( p ) { // w razie niepowodzenia alokacji pamięci zwróci nullptr
       
p->x = 5;
       
p->y = 6;
       
printf( "Punkt: (%d, %d)\n", p->x, p->y );
       
delete p;
   
}
}
Alokacja tablicy:
C/C++
#include <stdio.h>
#include <stdlib.h>

typedef struct {
   
int x, y;
} Point;

int main() {
   
int n = 3;
   
Point * arr = malloc( n * sizeof( Point ) ); // Alokacja tablicy obiektów
   
if( arr ) {
       
for( int i = 0; i < n; ++i ) {
           
arr[ i ].x = i;
           
arr[ i ].y = i * 2;
           
printf( "arr[%d] = (%d, %d)\n", i, arr[ i ].x, arr[ i ].y );
       
}
       
free( arr ); // zwolnienie tablicy obiektów
   
}
}
Alokacja tablicy:
C/C++
#include <print>

struct Point {
   
int x, y;
   
Point( int x = 0, int y = 0 )
        :
x( x )
       
, y( y )
   
{
       
std::println( "Konstruktor: ({}, {})", x, y );
   
}
   
~Point() {
       
std::println( "Destruktor: ({}, {})", x, y );
   
}
}
;

int main() {
   
int n = 3;
   
Point * arr = new Point[ n ]; // alokacja n obiektów wywołuje konstruktor domyślny n razy
    // Point* arr = new Point[n]{ {1, 2}, {3, 4} };     // mozemy tez podac wartosci (w tym zawolac odpowiednie konstruktory)
   
for( int i = 0; i < n; ++i ) {
       
arr[ i ].x = i;
       
arr[ i ].y = i * 2;
       
std::println( "arr[{}] = ({}, {})", i, arr[ i ].x, arr[ i ].y );
   
}
   
delete[ ] arr; // wywołanie destruktorów i zwolnienie pamięci
}
Alokacja tablicy wraz z zerowaniem:
C/C++
#include <stdio.h>
#include <stdlib.h>

int main() {
   
int n = 5;
   
int * arr = calloc( n, sizeof( int ) ); // pamięć wyzerowana
   
if( arr ) {
       
for( int i = 0; i < n; ++i ) {
           
printf( "arr[%d] = %d\n", i, arr[ i ] );
       
}
       
free( arr );
   
}
}
Alokacja tablicy wraz z zerowaniem:
C/C++
#include <print>

int main() {
   
int n = 5;
   
int * arr = new int[ n ] { }; // pamięć wyzerowana dzięki {}
   
for( int i = 0; i < n; ++i ) {
       
std::println( "arr[{}] = {}", i, arr[ i ] );
   
}
   
delete[ ] arr;
}
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
.
Podejście ręczne (globalny operator
new
)
Podejście zalecane - przy użyciu kontenera
std::vector
C/C++
#include <print>
#include <cmath>  
// std::sin

struct Point
{
   
double x, y;
   
Point( double x, double y )
        :
x( x )
       
, y( y )
   
{
       
std::println( "Konstruktor: ({}, {})", x, y );
   
}
   
~Point()
   
{
       
std::println( "Destruktor: ({}, {})", x, y );
   
}
}
;

int main()
{
   
int N = 4;
   
auto * arr = static_cast < Point * >(::operator new[ ]( sizeof( Point ) * N ) );
   
for( int i { }; i < N; ++i )
   
{
       
new( & arr[ i ] ) Point( i, std::sin( i ) ); // wywołanie konstruktora, tzw. placement new
        // std::construct_at(&p[i], i, std::sin(i)); // alternatywne do powyższego
   
}
   
for( int i { }; i < N; ++i )
       
 std::println( "arr[{}] = ({}, {})", i, arr[ i ].x, arr[ i ].y );
   
   
for( int i = N - 1; i-- > 0; )
   
{
       
arr[ i ].~Point();
       
// std::destroy_at(&arr[i]); // alternatywne do powyższego
   
}
   
::operator delete[ ]( arr );
}
C/C++
#include <print>
#include <memory>
#include <vector>

struct Point
{
   
int x, y;
   
Point( int x, int y )
        :
x( x )
       
, y( y )
   
{
       
std::println( "Konstruktor: ({}, {})", x, y );
   
}
   
~Point()
   
{
       
std::println( "Destruktor: ({}, {})", x, y );
   
}
}
;

int main()
{
   
int n = 3;
   
std::vector < Point > arr;
   
arr.reserve( n );
   
for( int i = 0; i < n; ++i )
   
{
       
arr.emplace_back( i, i * 2 );
   
}
}
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>
):
Inteligentny wskaźnik opis przykład
std::unique_ptr
Wskaźnik będący unikalnym zarządcą pamięci na którą wskazuje.
Używaj
std::unique_ptr
 tam, gdzie możliwe – jest najlżejszy i najbezpieczniejszy.
C/C++
#include <memory>
#include <print>

int main()
{
   
std::unique_ptr < int > ptr1 { new int { 4 } };
   
if( ptr1 ) // czy nie nullptr
       
 std::println( "Wartość pod wskaźnikiem: {}", * ptr1 );
   
   
std::unique_ptr < int > ptr2 = std::make_unique < int >( 5 ); // lepsza alternatywa do powyższego - unikamy jawnego użycia słówka "new"
   
auto ptr3 = std::make_unique < int >( 6 ); // najlepsza forma - po co pisać dwukrotnie typ
   
   
* ptr3 = 7;
   
   
// auto ptr4 = ptr2; // błąd kompilacji - unique_ptr są niekopiowalne!
   
auto ptr5 = std::move( ptr2 ); // OK, można przenieść zawartość
   
   
int * ptr = ptr5.get(); // uchwyt do zwykłego wskaźnika
} // brak delete – obiekt zostanie zwolniony automatycznie
Dostępna jest również wersja tablicowa:
C/C++
#include <memory>

int main()
{
   
std::unique_ptr < int[ ] > ptr1 { new int[ 2 ] }; // alokacja całej tablicy obiektów
   
ptr1[ 0 ] = 1;
   
ptr1[ 2 ] = 2;
   
   
auto ptr3 = std::make_unique < int[ ] >( 2 ); // alokuje tablice
   
   
int * ptr = ptr5.get(); // uchwyt do zwykłego wskaźnika
} // brak delete – obiekt zostanie zwolniony automatycznie
Własny sposób zwalniania:
C/C++
#include <memory>
#include <print>

void my_deleter( int * p )
{
   
std::println( "Zwalniam zasób: {}", * p );
   
delete p;
}

int main()
{
   
std::unique_ptr < int, decltype( & my_deleter ) > ptr( new int { 123 }, my_deleter );
}
std::shared_ptr
Wskaźnik dzielący własność – zasób zostaje zwolniony, gdy ostatni
shared_ptr
 przestanie do niego wskazywać.
std::shared_ptr
 używaj tylko, gdy naprawdę musisz współdzielić własność.
Jak tylko możesz to nie używaj ręcznie
new
, tylko zawsze
make_shared
C/C++
#include <memory>
#include <print>

int main()
{
   
std::shared_ptr < int > ptr1 { new int { 4 } }; // niezalecane stosowanie new (mniejsza wydajnosc)
   
auto ptr2 = std::make_shared < int >( 10 );
   
auto ptr3 = ptr2; // dzielenie własności
   
std::println( "{} {}", * ptr2, * ptr3 ); // 10 10
   
std::println( "Licznik referencji: {}", ptr2.use_count() ); // np. 2
   
    /// wersja tablicowa:
   
std::shared_ptr < int[ ] > arr { new int[ 2 ] }; // wersja tablicowa
   
auto arr2 = std::make_shared < int[ ] >( 2 ); // wersja tablicowa - preferowana forma
   
   
arr[ 0 ] = 1;
   
arr[ 2 ] = 2;
   
   
/// własny deleter
   
std::shared_ptr < int > ptr { new int { 4 },[ ]( int * data ) {
           
std::println( "Zwalniam zasób: {}", * data );
           
delete data;
       
} };
}
std::weak_ptr
Słaby wskaźnik do zasobu zarządzanego przez
shared_ptr
, nie zwiększa liczby odniesień – można sprawdzić, czy zasób nadal istnieje.
std::weak_ptr
 zabezpiecza przed cyklami (gdyby np. zrobić drzewo, w którym rodzic i dziecko korzystają z
shared_ptr
 to byłby wyciek zasobów) i pozwala monitorować istnienie zasobu.
C/C++
#include <memory>
#include <print>

int main()
{
   
std::weak_ptr < int > weak;
   
{
       
auto strong = std::make_shared < int >( 123 );
       
weak = strong;
       
       
if( auto locked = weak.lock() ) // locked jest std::shared_ptr
           
 std::println( "Dostęp przez weak_ptr: {}", * locked );
       
   
} // strong znika – zasób usunięty
   
   
if( weak.expired() )
       
 std::println( "Zasób już nie istnieje" );
   
}
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
).
C/C++
#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.
C23 C++23
C/C++
#include <stdio.h>

void log_message( const char * msg, const char * file, int line, const char * func )
{
   
printf( "%s:%d w %s(): %s\n", file, line, func, msg );
}
#define LOG(text) log_message(text, __FILE__, __LINE__, __func__);

int main()
{
   
log_message( "Test logowania", __FILE__, __LINE__, __func__ );
   
LOG( "Test logowania z makra" );
}
C/C++
#include <print>
#include <source_location>

void log_message( const char * msg, const std::source_location & loc = std::source_location::current() )
{
   
std::println( "{}:{} w {}(): {}", loc.file_name(), loc.line(), loc.function_name(), msg );
}

int main()
{
   
log_message( "Test logowania" );
}
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++:
C/C++
#include <print>
#include <typeinfo>  
// typeid

struct Shape
{
   
virtual ~Shape() = default;
};

struct Circle
    : Shape
{ };

struct A { }; // A nie ma nic virtualnego
struct B
    : A
{ };

int main() {
   
Circle c;
   
const Shape & s = c;
   
std::println( "Typ: {}", typeid( s ).name() ); // np. "Typ: 6Circle"
   
   
B b;
   
const A & a = b;
   
std::println( "Typ: {}", typeid( a ).name() ); // np. "Typ: 1A"
   
   
int i { };
   
const int ci = i;
   
const int & cref = i;
   
std::println( "Mamy int, wykrywa: {}", typeid( i ).name() ); // typ np. "i"
   
std::println( "Mamy const int, wykrywa: {}", typeid( ci ).name() ); // typ np. "i"
   
std::println( "Mamy const int &, wykrywa: {}", typeid( cref ).name() ); // typ np. "i"
   
if( typeid( i ) == typeid( ci ) ) // warunek spelniony
       
 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:
C/C++
#include <print>
#include <boost/type_index.hpp>
namespace bt = boost::typeindex;

struct Shape
{
   
virtual ~Shape() = default;
};

struct Circle
    : Shape
{ };

struct A { }; // A nie ma nic virtualnego
struct B
    : A
{ };

int main() {
   
Circle c;
   
const Shape & s = c;
   
std::println( "{}", bt::type_id_runtime( s ).pretty_name() ); // Circle
   
std::println( "{}", bt::type_id_with_cvr < decltype( s ) >().pretty_name() ); // Shape const&
   
   
B b;
   
const A & a = b;
   
std::println( "{}", bt::type_id_runtime( a ).pretty_name() ); // A
   
std::println( "{}", bt::type_id_with_cvr < decltype( a ) >().pretty_name() ); // A const&
   
   
int i { };
   
const int ci = i;
   
const int & cref = i;
   
std::println( "{}", bt::type_id_runtime( i ).pretty_name() ); // int
   
std::println( "{}", bt::type_id_runtime( ci ).pretty_name() ); // int
   
std::println( "{}", bt::type_id_with_cvr < decltype( ci ) >().pretty_name() ); // int const
   
std::println( "{}", bt::type_id_runtime( cref ).pretty_name() ); // int
   
std::println( "{}", bt::type_id_with_cvr < decltype( cref ) >().pretty_name() ); // int const&
   
if( bt::type_id_runtime( i ) == bt::type_id_runtime( ci ) ) // warunek spelniony
       
 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ść:
C23 C++23
Użyto Berkeley Sockets (opisano w artykule) Użyto
boost::asio
C/C++
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <sys/socket.h>

#define PORT 12345
#define BUFFER_SIZE 1024

int main()
{
   
int server_fd, client_fd;
   
struct sockaddr_in address;
   
socklen_t addrlen = sizeof( address );
   
char buffer[ BUFFER_SIZE ];
   
   
server_fd = socket( AF_INET, SOCK_STREAM, 0 );
   
if( server_fd == - 1 )
   
{
       
perror( "socket" );
       
return 1;
   
}
   
   
address.sin_family = AF_INET;
   
address.sin_addr.s_addr = INADDR_ANY;
   
address.sin_port = htons( PORT );
   
   
if( bind( server_fd,( struct sockaddr * ) & address, sizeof( address ) ) < 0 )
   
{
       
perror( "bind" );
       
return 1;
   
}
   
   
if( listen( server_fd, 1 ) < 0 )
   
{
       
perror( "listen" );
       
return 1;
   
}
   
   
printf( "Serwer nasłuchuje na porcie %d...\n", PORT );
   
client_fd = accept( server_fd,( struct sockaddr * ) & address, & addrlen );
   
if( client_fd < 0 )
   
{
       
perror( "accept" );
       
return 1;
   
}
   
   
printf( "Połączono z klientem.\n" );
   
   
while( 1 )
   
{
       
ssize_t n = read( client_fd, buffer, BUFFER_SIZE );
       
if( n <= 0 )
       
{
           
break; // klient się rozłączył
       
}
       
write( client_fd, buffer, n ); // odeślij to, co przyszło
   
}
   
   
printf( "Rozłączono klienta.\n" );
   
close( client_fd );
   
close( server_fd );
   
return 0;
}
C/C++
#include <boost/asio.hpp>
#include <boost/asio/awaitable.hpp>
#include <boost/asio/use_awaitable.hpp>
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/detached.hpp>
#include <iostream>

using boost::asio::awaitable;
using boost::asio::ip::tcp;
using boost::asio::use_awaitable;
namespace net = boost::asio;

awaitable < void > echo_session( tcp::socket socket )
{
   
try {
       
char data[ 1024 ];
       
for(;; ) {
           
std::size_t n = co_await socket.async_read_some( net::buffer( data ), use_awaitable );
           
co_await net::async_write( socket, net::buffer( data, n ), use_awaitable );
       
}
    }
catch( std::exception & e ) {
       
std::cerr << "Session ended: " << e.what() << "\n";
   
}
}

awaitable < void > listener( uint16_t port )
{
   
tcp::acceptor acceptor( co_await net::this_coro::executor, { tcp::v4(), port } );
   
for(;; ) {
       
tcp::socket socket = co_await acceptor.async_accept( use_awaitable );
       
net::co_spawn( acceptor.get_executor(), echo_session( std::move( socket ) ), net::detached );
   
}
}

int main()
{
   
try {
       
net::io_context io;
       
net::co_spawn( io, listener( 12345 ), net::detached );
       
io.run();
   
} catch( std::exception & e ) {
       
std::cerr << "Fatal: " << e.what() << "\n";
   
}
}
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:
  • wykrywa język i wersję (C/C++ wraz ze standardem),
  • wyświetla kompilator oraz jego wersję,
  • sprawdza obecność wybranych nagłówków i atrybutów,
  • drukuje architekturę i system operacyjny,
  • jest opakowany w
    extern "C"
    , jeśli używany z C++ - dzięki temu funkcja nie będzie manglowana przez kompilator C++

Plik: common_features.h

C/C++
#ifndef COMMON_FEATURES_H
#define COMMON_FEATURES_H

#ifdef __cplusplus
extern "C" {
   
#endif
   
   
void print_language_info( void );
   
   
#ifdef __cplusplus
}
#endif

#endif  
// COMMON_FEATURES_H

Plik: common_features.c lub common_features.cpp

C/C++
#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
   
   
// Kompilator
   
#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
   
   
// System operacyjny
   
#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
   
   
// Architektura
   
#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
   
   
// Sprawdzenie nagłówka
   
#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
   
   
// Atrybut C23
   
#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
   
   
// Atrybuty C++
   
#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

C/C++
#include "common_features.h"

int main( void )
{
   
print_language_info();
}

Przykład użycia na Linuxie

C23 C++23
kompilator
gcc
kompilator
g++
gcc -std=c23 common_features.c main.c -o info && ./info

==== Informacje o środowisku kompilacji ====
Język:       C
Standard:    C202311
Kompilator:  GCC 15.1.1
System:      Linux
Architektura: x86_64 (64-bit)
[✓] <threads.h> dostępny
[✓] [[maybe_unused]] (C23) wspierany
============================================
g++ -std=c++23 common_features.cpp main.cpp -o info && ./info

==== Informacje o środowisku kompilacji ====
Język:       C++
Standard:    C++202302
Kompilator:  GCC 15.1.1
System:      Linux
Architektura: x86_64 (64-bit)
[✓] <threads.h> dostępny
[✓] [[maybe_unused]] (C23) wspierany
[✓] [[nodiscard]] (C++) wspierany
============================================
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.
  • C23 to prosty, niezawodny język do programowania niskopoziomowego, niczym solidny młotek – idealny do systemów wbudowanych.
  • C++23 to wszechstronny scyzoryk wielofunkcyjny, wspierający programowanie obiektowe, generyczne i funkcjonalne, doskonały do aplikacji wysokopoziomowych.
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