C++ jest językiem, który wciąż ewoluuje. Od C++11 co 3 lata publikowana jest nowa wersja standardu. Publikacje proponujące zmiany (aktualna lista online) są nieustannie wysyłane do Komitetu C++, który głosuje nad rozwiązaniami. Dotyczą one zarówno samego języka jak i biblioteki standardowej. Prace nad C++23 zostały zakończone, a Komitet zajmuje się obecnie C++26.
W artykule przedstawię moim zdaniem najważniejsze zmiany w języku w standardzie C++23.
if consteval
Powtórka z if constexpr
Najpewniej wiesz już, czym jest if constexpr. Pozwala warunkowo kompilować instrukcje (statements). Po nim występuje warunek, którego wartość musi być wyrażeniem kontekstowo przekształconym na bool, zaś konwersja musi być wyrażeniem stałym (constant expression).
template<typename T>
decltype(auto) CallFooOrBarMember(const T& t)
{
if constexpr (requires { t.Foo(); }) { return t.Foo(); }
else if constexpr (requires { t.Bar(); }) { return t.Bar(); }
}
struct Caller0
{
int Foo() const { return 42; }
char Bar() const { return 'E'; }
};
struct Caller1
{
int Foo() { return 7; }
static double Bar() { return 3.14; }
};
W powyższym przykładzie:
- typem zwracanym przez CallFooOrBarMember(Caller0{}) jest int. CallFooOrBarMember woła Caller0::Foo, ponieważ pierwszym spełnionym warunkiem jest to, że wyrażenie t.Foo(); jest prawidłowe dla const Caller0& t (t.Bar(); jest również prawidłowe dla const Caller0& t, ale przez spełnienie pierwszego warunku, gałąź if constexpr zostaje pominięta.
- typem zwracanym przez CallFooOrBarMember(Caller1{}) jest double. CallFooOrBarMember woła Caller1::Bar, ponieważ t.Foo(); jest prawidłowe dla Caller1& t, ale nie dla const Caller1& t, co zostawia prawidłowość wyrażenia t.Bar(); jako pierwszy spełniony warunek.
- typem zwracanym przez CallFooOrBarMember(int{}) jest void, ponieważ int nie spełnia żadnego warunku, co zostawia puste ciało funkcji.
Czas wykonywania (run time) a czas kompilacji (compile time)
Wersja C++20 dostarczyła funkcję do rozpoznania, czy jej wywołanie odbywa się w kontekście określania stałej wartości (constant-evaluated context, wartość może być określona w czasie kompilacji). Była to funkcja is_constant_evaluated().
Oto przykład użycia (zakładając, że std::strlen nie może być wołane w kontekście określania stałej wartości):
constexpr size_t stringLength(const char* str)
{
if (std::is_constant_evaluated())
{
size_t size = 0;
while (*str != '\0') { ++size; ++str; }
return size;
}
else
{
return std::strlen(str);
}
}
Jak widać, jest to zwyczajna instrukcja if, a nie if constexpr. Powodem tego jest zachowanie funkcji is_constant_evaluated. Zwraca ona true, gdy jest w kontekście określania stałej wartości, a wstawianie jej wywołania w if constexpr tworzy taki kontekst, co spowodowałoby odrzucenie gałęzi false (nie jest w ogóle kompilowana), która jest dla kontekstu run time.
Taki sposób użycia tej funkcji jest podatny na błędy, dlatego C++23 dostarczył: if consteval. Instrukcja nie wykorzystuje warunku, ponieważ warunek określania stałej wartości (constant evaluation, czasu kompilacji) jest w jej nazwie. Zastosowanie tej instrukcji jest takie samo, jak wspomnianej funkcji, ale teraz jest to bardziej elegancka funkcjonalność samego języka bez wykorzystania żadnej biblioteki.
Ostatni przykład można zmienić na:
constexpr size_t stringLength(const char* str)
{
if consteval
{
size_t size = 0;
while (*str != '\0') { ++size; ++str; }
return size;
}
else
{
return std::strlen(str);
}
}
Należy wspomnieć, że instrukcja gałęzi true (oraz instrukcja gałęzi false, gdy jest obecna) musi być instrukcją złożoną (compound statement).
if consteval doThis(); else doThat(); // błąd
if consteval { doThis(); } else { doThat(); } // prawidłowo
Można też użyć alternatywnej instrukcji:
if !consteval { nonConstEvalContext(); } else { constEvalContext(); }
która odpowiada:
if consteval { constEvalContext(); } else { nonConstEvalContext(); }
Jawny parametr obiektu (explicit object parameter)
Istnieje nowy sposób, by zadeklarować niestatyczną funkcję składową (non-static member function): jako pierwszy parametr funkcja przyjmuje jawny parametr obiektu poprzedzony słowem kluczowym this. Na potrzeby tego artykułu nazwijmy to FEOP (function with explicit object parameter – funkcja z jawnym parametrem obiektu).
struct OldAppleTree
{
int appleCount;
void pickApple(int count) &;
int getCombinedAppleCount(const AppleTree& other) const &;
};
struct AppleTree
{
int appleCount;
// odpowiedniki funkcji struktury OldAppleTree:
void pickApple(this AppleTree& self, int count);
int getCombinedAppleCount(this const AppleTree& self, const AppleTree& other);
// bez odpowiadającej wersji; przekazywanie obiektu przez wartość (pass by value, kopiuje *this)
int getTripledAppleCount(this AppleTree self);
};
Wydaje się bardziej rozwlekłe niż stary sposób. Zanim ujawnię jego potencjał do upraszczania rzeczy, chciałbym wspomnieć o paru różnicach:
- wskaźnik this nie może być użyty w ciele FEOP
void AppleTree::pickApple(this AppleTree& self, int count)
{
// appleCount -= count; // błąd
// this->appleCount -= count; // błąd
self.appleCount -= count;
}
- wskaźnik na FEOP jest wskaźnikiem na funkcję, a nie wskaźnikiem na składową klasy (member)
OldAppleTree oldTree{6};
auto oldFun = &OldAppleTree::pickApple;
// typem oldFun jest: void (OldAppleTree::*)(int) &'
(oldTree.*oldFun)(2);
AppleTree newTree{7};
auto newFun = &AppleTree::pickApple;
// typem newFun jest: void (*)(AppleTree&, int)'
newFun(newTree, 2);
- FEOP musi być niestatyczna, niewirtualna oraz bez kwalifikatorów cv (cv-qualifiers: const, volatile) i kwalifikatorów ref (ref-qualifiers: &, &&) (te kwalifikatory, o ile obecne, powinny być stosowane przy jawnym parametrze obiektu np. void fun(this const X& self)).
Używanie jawnego parametru obiektu w szablonach funkcji składowych (member function templates) pozwala na dedukcję typu i kategorii wartości (value category) wywołującego obiektu. Ta funkcjonalność to „deducing this„. Pozwala ona na zredukowanie kodu, jeśli chodzi o kwalifikatory cv i ref:
class OldVec
{
private:
Apple* array;
public:
Apple& operator[](size_t id) & { return array[id]; }
const Apple& operator[](size_t id) const & { return array[id]; }
Apple&& operator[](size_t id) && { return std::move(array[id]); }
const Apple&& operator[](size_t id) const && { return std::move(array[id]); }
};
class NewVec
{
private:
Apple* array;
public:
// skrócony szablon funkcji (abbreviated function template) przy użyciu parametru typu auto
auto&& operator[](this auto&& self, size_t id)
{
// forward_like propaguje kategorię wartości (value category)
// oraz kwalifikację const (const-qualification)
return std::forward_like<decltype(self)>(self.array[id]);
}
};
Oto jest DRY w akcji.
Co to std::forward_like? W skrócie – pozwala propagować kategorię wartości i kwalifikację const. Działa podobnie do std::forward, z tym że pierwszym parametrem szablonu może być typ zupełnie inny od typu parametru funkcji i zwraca referencję analogiczną do pierwszego parametru szablonu.
Apple apple;
const Apple capple;
std::forward_like<const NewVec&&>(std::move(apple)); // zwraca const Apple&&
std::forward_like<const NewVec&&>(apple); // zwraca const Apple&&
std::forward_like<NewVec&>(std::move(apple)); // zwraca Apple&
std::forward_like<NewVec&>(apple); // zwraca Apple&
std::forward_like<NewVec&>(std::move(capple)); // zwraca const Apple&
std::forward_like<NewVec&>(capple); // zwraca const Apple&
CRTP
Curiously Recurring Template Pattern (tzw. ciekawie powtarzający się wzór szablonu) można go uprościć, używając dedukującego this:
- po staremu: klasa bazowa musi być szablonowa
template<typename Derived>
struct Base
{
void process()
{
commonCalculations();
static_cast<Derived*>(this)->specializedPart();
commonCalculations2();
}
};
struct Deriv : public Base<Deriv> { specializedPart() {} };
Deriv d;
d.process();
- po nowemu: funkcje składowe (member functions) są szablonowe, a klasa dziedzicząca jest wydedukowana
struct Base
{
void process(this auto&& self)
{
commonCalculations();
self.specializedPart();
commonCalculations2();
}
};
struct Deriv : public Base { specializedPart() {} };
Deriv d;
d.process();
Jak widać, jest to mniej podatne na błędy, ponieważ nie używa się jawnie nazwy klasy dziedziczącej.
Wielowymiarowy operator indeksu (multidimensional subscript operator)
C++23 pozwala na przeciążenie operatora indeksu (subscript operator, operator[]) dla więcej niż jednego parametru:
struct Apple { int seedCount = 0; };
class The5DArray
{
private:
Apple* appleArray;
std::array<size_t, 5> dim;
public:
The5DArray(Apple* appleArray, const std::array<size_t, 5>& dim)
: appleArray{appleArray}, dim{dim} {}
Apple& operator[](size_t id0, size_t id1, size_t id2, size_t id3, size_t id4)
{
return appleArray[id0 * dim[1] * dim[2] * dim[3] * dim[4]
+ id1 * dim[2] * dim[3] * dim[4]
+ id2 * dim[3] * dim[4]
+ id3 * dim[4]
+ id4];
}
};
int main()
{
constexpr std::array<size_t, 5> dimensions{ 3, 3, 3, 3, 3 };
constexpr size_t dimProduct = std::ranges::fold_left(dimensions, 1,
std::multiplies<size_t>());
Apple appleArray[dimProduct]{};
The5DArray a5DArray{ appleArray, dimensions };
a5DArray[2, 1, 2, 2, 1].seedCount += 2;
}
Operatory statyczne (static operators)
Zarówno operator wywołania (call operator, operator()) jak i operator indeksu (subscript operator, operator[]) mogą być zadeklarowane jako statyczne, używając słowa static tj. nie są związane z konkretnym obiektem i wywoływanie ich nie przekazuje niejawnego parametru obiektu (implicit object parameter). To jeden argument mniej do przekazania i dla często wołanych obiektów funkcyjnych (function objects) może robić różnicę.
struct OldLess
{
constexpr bool operator()(const Tree& tl, const Tree& tr) const noexcept
{
// możliwość odwołania do this
return tl.appleCount < tr.appleCount;
}
};
struct NewLess
{
static constexpr bool operator()(const Tree& tl, const Tree& tr) noexcept
{
// BRAK możliwości odwołania do this
return tl.appleCount < tr.appleCount;
}
};
// wywołania wyglądają tak samo
OldLess ol;
bool v0 = ol(Tree{1}, Tree{2});
NewLess nl;
bool v1 = nl(Tree{1}, Tree{2}); // nie jest przekazywany wskaźnik na nl
Jeśli nie masz obiektu, możesz wywołać albo NewLess{}(3) albo NewLess::operator()(3), ponieważ NewLess(3) jest wciąż interpretowany jako konstruktor (chociaż dla typu NewLess nie ma odpowiadającego konstruktora dla tego wywołania).
Kod poniżej symuluje funkcje języka Rust, ponieważ każda funkcja ma swój unikatowy typ.
inline constexpr NewLess newLess{};
newLess(t0, t1);
std::ranges::sort(treeVector, newLess);
Typ porównujący (compare type) funkcji std::ranges::sort jest wydedukowany jako NewLess. Obiekt newLess jest przekazany przez wartość (passed by value) raz i każde wywołanie jego operatora wywołania operator() jest pozbawione przekazywania samego obiektu.
Co z lambdami?
Lambdy mogą być teraz zadeklarowane jako static, co czyni ich operator wywołania (call operator) static:
auto add = [](int a, int b) static { return a + b; };
int sum = add(1, 3); // obiekt domknięcia (closure object) sam w sobie
// nie jest przekazywany do operatora wywołania
Gdy lambda jest zadeklarowana jako static, nie może być mutable – zmienność (mutability) jest połączona ze stanem pewnego obiektu, a funkcja static nie jest powiązana z żadnym obiektem. Lambdy są obiektami funkcyjnymi. Wywoływanie lambdy bez static niejawnie przekazuje wskaźnik na daną lambdę. Wywoływanie lambdy ze static nie przekazuje tego dodatkowego parametru. Ponadto w lambdach ze static nie można przekazywać zmiennych „capture” ([] powinno być puste).
Decay-copy jako funkcjonalność języka
Nie taki straszny rozkład
Rozkład (decay) to rodzaj niejawnej konwersji:
- Rozkład tablicy na wskaźnik (array-to-pointer decay) odbywa się gdy wartość (dowolnej kategorii) typu T[N] lub T[] jest niejawnie konwertowana do pr-wartości (prvalue) typu T*, a wynik wskazuje na pierwszy element tablicy.
- Rozkład funkcji na wkaźnik (function-to-pointer decay) odbywa się gdy l-wartość (lvalue) funkcji typu R(Arg…) jest niejawnie konwertowana do pr-wartości (prvalue) typu R(*)(Arg…), a wynik wskazuje na tę funkcję. Dotyczy to tylko funkcji swobodnych (free functions) oraz statycznych funkcji składowych (static member functions), ponieważ l-wartości odnoszące się do niestatycznych funkcji składowych nie istnieją.
- Usunięcie referencji oraz kwalifikatorów cv (jak po zastosowaniu std::remove_cvref_t, np. const T&& zmienia się w T).
Rozkład odbywa się m.in. gdy wołany jest szablon funkcji, którego argument jest przekazywany przez wartość (passed by value). Ponieważ przekazywanie przez wartość powoduje kopię l-wartości, to połączenie zostało nazwane decay-copy (kopiowanie po rozkładzie).
template<typename T> void fun(T t) {}
int arr0[3];
fun(arr0); // T wydedukowany jako int*
using T0 = std::decay_t<decltype(arr0)>; // T0 to int*
int arr1[3][4];
fun(arr1); // T wydedukowany jako int(*)[4]
using T1 = std::decay_t<decltype(arr1)>; // T1 to int(*)[4]
double fun2(int, int) { return {}; } // decltype(fun2) to double(int, int)
fun(fun2); // T wydedukowany jako double(*)(int, int)
using T2 = std::decay_t<decltype(fun2)>; // T2 to double(*)(int, int)
const int& i = 3;
fun(i); // T wydedukowany jako int
using T3 = std::decay_t<decltype(i)>; // T3 to int
Specyfikator auto znów upraszcza
Od C++23 można używać nowej jawnej konwersji typu: auto(x) oraz auto{x}. W tej konwersji auto jest zastępowane przez decltype(var), gdzie var jest hipotetyczną zmienną zadeklarowaną jako odpowiednio auto var(x); lub auto var{x};, a wynik jest zawsze pr-wartością (prvalue) wydedukowanego typu.
template<typename T> void fun(T&& t) {}
int arr0[3];
fun(arr0); // T wydedukowany jako int(&)[3]
fun(auto{arr0}); // T wydedukowany jako int*
using T0 = int*; fun(T0{arr0}); // jak wyżej
int arr1[3][4];
fun(arr1); // T wydedukowany jako int(&)[3][4]
fun(auto{arr1}); // T wydedukowany jako int(*)[4]
using T1 = int(*)[4]; fun(T1{arr1}); // jak wyżej
double fun2(int, int) { return {}; } // decltype(fun2) to double(int, int)
fun(fun2); // T wydedukowany jako double(&)(int, int)
fun(auto{fun2}); // T wydedukowany jako double(*)(int, int)
using T2 = double(*)(int, int); fun(T2{fun2}); // jak wyżej
const int& i = 3;
fun(i); // T wydedukowany jako const int&
fun(auto{i}); // T wydedukowany jako int
using T3 = int; fun(T3{i}); // jak wyżej
Załóżmy, że chcemy zastąpić wszystkie elementy zakresu (range), które są równe pierwszemu elementowi:
void replaceAllEqualToFirst(auto& rng, const auto& newValue)
{
std::ranges::replace(rng, rng.front(), newValue);
}
Apple getBetterApple() { return {}; }
std::vector<Apple> appleVec;
replaceAllEqualToFirst(appleVec, getBetterApple());
To nie zadziała tak, jak założyliśmy, ponieważ po zastąpieniu pierwszego elementu nowym, następne elementy są porównywane z podmienionym pierwszym elementem. By to naprawić należy stworzyć kopię:
void replaceAllEqualToFirst(auto& rng, const auto& newValue)
{
auto firstElement = rng.front();
std::ranges::replace(rng, firstElement, newValue);
}
Jak można to uprościć? Można pominąć zmienną lokalną, ale będzie to wymagać dedukcji typu:
void replaceAllEqualToFirst(auto& rng, const auto& newValue)
{
using T = std::decay_t<decltype(rng.front())>;
std::ranges::replace(rng, T(rng.front()), newValue);
}
Podsumowując – nie za bardzo prościej. Rzutowanie auto na ratunek:
void replaceAllEqualToFirst(auto& rng, const auto& newValue)
{
std::ranges::replace(rng, auto(rng.front()), newValue);
}
auto dedukuje typ i tworzy kopię obiektu, na przykładzie:
int arr[] = { -1, 0, -1, 2, 3, -1 };
replaceAllEqualToFirst(arr, -2);
kopiuje pierwszy element (int(rng.front()), czyli -1) i wykorzystuje tę tymczasową kopię do porównywania w std::ranges::replace.
Przygotowanie na przyszłość
Biorąc stan na 25.09.2024, część zmian wciąż nie została zaimplementowana w MSVC, podczas gdy GCC i Clang są zaktualizowane (obecny stan na cppreference). Nawet jeśli nie możesz obecnie używać tych funkcjonalności, posiadanie wstępnej wiedzy na ich temat będzie przydatne w przyszłości.
***
Jeśli interesuje Cię język C++, zajrzyj również do innych artykułów naszych ekspertów.
Zostaw komentarz