CodeGym /Kursy /C++ SELF /Standardowe concepts — std::integral, std::floating_poi...

Standardowe concepts — std::integral, std::floating_point, std::ranges::range

C++ SELF
Poziom 57, Lekcja 4
Dostępny

1. Po co są standardowe concepts

Kiedy zaczynasz pisać szablony, łatwo uwierzyć w bajkę: „Napiszesz template <typename T> raz — i będzie działać dla wszystkiego”. Rzeczywistość szybko sprowadza na ziemię: w ciele szablonu robisz x / 2 albo x.size(), a potem ktoś wywołuje twoją funkcję ze std::string lub std::vector, i kompilator wypluwa 200 linii diagnostyki (i oczywiście po angielsku, bo kompilator też jest programistą).

Standardowe concepts nie służą „do utrudniania życia”, lecz do uczciwszego kodu: jeśli funkcja jest przeznaczona tylko dla liczb całkowitych — niech to będzie widoczne bezpośrednio w sygnaturze. Jeżeli algorytm ma sens tylko dla typów zmiennoprzecinkowych — to samo. To wpływa na czytelność, sensowne komunikaty o błędach i poprawny kontrakt API. Historycznie idee „standardowych library concepts” były wprowadzane oddzielnymi propozycjami i decyzjami komitetu, i w dokumentach WG21 regularnie spotkasz odniesienia do “Standard library concepts” jako osobnego obszaru pracy.

Jak constraints „filtrować” typy

Gdy widzisz template <std::integral T>, ważne pamiętać: to nie jest sprawdzenie w czasie wykonania ani if. Kompilator nie uruchamia programu, żeby sprawdzić typ. On po prostu decyduje: „czy ta funkcja w ogóle istnieje dla danego T, czy nie”.

Oto przydatny model mentalny:

flowchart TD
    A[Istnieje wywołanie funkcji-szablonu] --> B[Kompilator dopasowuje T]
    B --> C{Sprawdzenie constraints}
    C -->|spełnione| D[Instancjacja ciała szablonu]
    C -->|nie spełnione| E[Funkcja nie pasuje / błąd kompilacji]

Chodzi o to, że std::integral, std::floating_point i std::ranges::range — to „bramy”, przez które typ albo przejdzie, albo nie.

2. std::integral: tylko liczby całkowite

Gdy mówimy „liczba całkowita” w C++, zazwyczaj myślimy o int. Ale w języku istnieje cała rodzina: short, long, long long, wszystkie warianty unsigned, plus char (tak, tak) a nawet bool (co czasem zaskakuje). W szablonach często chcesz ograniczyć funkcję właśnie do takich typów: np. dla indeksów, liczników, operacji bitowych, arytmetyki z %, dzielenia całkowitego itd.

std::integral — to standardowy concept z nagłówka <concepts>, który oznacza: „T — typ całkowity”. W praktyce traktuj to jako kategorię typów, a nie „sprawdzenie na int”. Czyli std::integral<int> i std::integral<long long> będą prawdziwe, i to właśnie jest potrzebne.

Krótka ściągawka po semantyce:

Concept O czym Typowi przedstawiciele
std::integral
„liczby całkowite”
int, long long, unsigned, char, bool
std::floating_point
„liczby zmiennoprzecinkowe”
float, double, long double

Minimalny przykład: funkcja tylko dla całkowitych

Zacznijmy od maksymalnie prostego i „uczciwego” przykładu. Niech mamy funkcję, która liczy liczbę „kroków”, i z założenia powinna działać tylko z typami całkowitymi.

#include <concepts>
#include <iostream>

void print_twice(std::integral auto x) {
    std::cout << (x * 2) << '\n'; // np.: 14
}

int main() {
    print_twice(7);
    // print_twice(3.14); // nie kompiluje się: 3.14 nie jest integral
}

Zwróć uwagę na formę std::integral auto x. To „ograniczony placeholder”: czyta się prawie jak zwykła funkcja, ale kontrakt jest wbudowany bezpośrednio w typ parametru.

Przykład w DataBox: czytamy dokładnie N wartości

Utrwalmy pomysł na aplikacji. Załóżmy, że w trakcie kursu tworzymy konsolowe narzędzie DataBox — mały analizator danych. Czyta liczby i wypisuje statystyki. Przed concepts zwykle pisaliśmy wersję dla int i dla double osobno, a teraz chcemy to ładnie uogólnić.

Na początek wprowadzimy funkcję, która czyta dokładnie N wartości. N — to liczba elementów i klasyczny kandydat na std::integral.

#include <concepts>
#include <iostream>
#include <vector>

std::vector<int> read_n_values(std::integral auto n) {
    std::vector<int> v;
    for (int i = 0; i < n; ++i) {
        int x = 0;
        std::cin >> x;
        v.push_back(x);
    }
    return v;
}

Tak, wewnątrz nadal używamy int i, i tak — są niuanse (np. jeśli n to long long). Ale na tym etapie ważny jest sens: parametr określający liczbę powinien być całkowity i jest to widoczne w sygnaturze.

Niuans: bool też jest integral

Możesz się kiedyś zdziwić: „Dlaczego moja funkcja przyjmuje true?” Bo bool należy do typów całkowitych. To nie błąd: w C++ tak było od dawna i concept odzwierciedla tę właściwość języka.

W praktycznym kodzie oznacza to prostą dyscyplinę: jeśli funkcja ma przyjmować „liczbę całkowitą, ale nie bool”, to samo std::integral nie wystarczy — trzeba dodać dodatkowe ograniczenia (ale to już temat kombinowania constraints i bardziej precyzyjnych wymagań; dzisiaj koncentrujemy się na standardowych elementach).

3. std::floating_point: dla liczb zmiennoprzecinkowych

Liczby zmiennoprzecinkowe — to te, które mają część ułamkową i związane z tym radości: błędy zaokrągleń, NaN, nieskończoności i „dlaczego 0.1 + 0.2 nie równa się 0.3”. Pracowaliście już z double i rozmawialiście o precyzji, więc logiczne jest czasem powiedzieć kompilatorowi: „Tylko typy zmiennoprzecinkowe”.

std::floating_point — standardowy concept z <concepts>, który oznacza: „typ — float, double lub long double (z uwzględnieniem kwalifikatorów cv)”. To bardzo przydatne, kiedy liczy się średnia, wariancja, normalizacja, procenty, operacje na pierwiastkach i potęgach — czyli wszystko, gdzie dzielenie całkowite zepsułoby sens.

Przykład: średnia tylko dla zmiennoprzecinkowych

Średnia to idealna demonstracja: na typach całkowitych (1 + 2) / 2 daje 1, a nie 1.5. Czasem to oczekiwane, a czasem — cicha logiczna pomyłka.

#include <concepts>
#include <iostream>

std::floating_point auto mean2(std::floating_point auto a,
                               std::floating_point auto b) {
    return (a + b) / 2; // 2 tutaj także jest floating w kontekście wyrażenia
}

int main() {
    std::cout << mean2(1.0, 2.0) << '\n'; // 1.5
    // std::cout << mean2(1, 2) << '\n';  // nie kompiluje się
}

Tu zrobiliśmy ciekawą rzecz: zarówno parametry, jak i typ wyniku — przez constrained auto. Czyta się to niemal jak „pseudokod”, i w tym tkwi jego siła.

Przykład w DataBox: średnia po danych

Pomyślmy teraz jak autorzy DataBox. Chcemy liczyć średnią ze std::vector<double>.

#include <concepts>
#include <iostream>
#include <vector>

double mean(const std::vector<double>& v) {
    double sum = 0.0;
    for (double x : v) {
        sum += x;
    }
    return v.empty() ? 0.0 : (sum / v.size());
}

Na razie to nie jest szablon. Ale idea jest taka: „Średnia” zazwyczaj prosi o typ zmiennoprzecinkowy. Jeśli później zechcemy uogólnić na T, std::floating_point będzie naturalnym ograniczeniem.

4. Kiedy integral i floating_point dają inny sens

Ograniczenia stają się szczególnie zrozumiałe, gdy widzisz dwie implementacje obok siebie. To jak dwa drzwi: „Wejście dla całkowitych” i „Wejście dla zmiennoprzecinkowych”. A kompilator — to twój ochroniarz (niekoniecznie przyjazny, ale sprawiedliwy).

Wyobraźmy sobie, że DataBox potrafi robić „normalizację na 100” (np. przeliczenie na procenty). Dla typów całkowitych to dziwne: szybko stracisz część ułamkową. Dla zmiennoprzecinkowych — normalne.

#include <concepts>
#include <iostream>

std::floating_point auto to_percent(std::floating_point auto x) {
    return x * 100.0;
}

int main() {
    std::cout << to_percent(0.123) << '\n'; // 12.3
    // std::cout << to_percent(5) << '\n';  // nie kompiluje się
}

I tu nadchodzi moment, w którym constraints nie „zabraniają”, lecz chronią sens. Mówisz dosłownie: „Jeśli przesłałeś tutaj int, to może zatrzymajmy się i zastanówmy”.

5. std::ranges::range: wszystko, po czym można iterować

Przed ranges wiele razy iterowaliście po std::vector pętlą for, a później poznaliście range-for: for (auto& x : v). Na poziomie odczuć to wygląda tak: „jeśli po obiekcie można przebiec się w range-for, to jest to kontener (lub coś podobnego)”. W C++ to uogólniono terminem range: obiekt, z którego można uzyskać początek i koniec (umownie, begin i end).

std::ranges::range — standardowy concept z <ranges>. Oznacza: „z obiektu można uzyskać iteratory początku i końca (przez mechanizmy ranges)”. To nie musi być std::vector; może to być std::array, std::string, std::list, jakiś view, albo nawet twój własny typ, jeśli zaimplementowałeś dla niego begin/end.

W dokumentach WG21 i dyskusjach LWG często pojawiają się „range concepts” i wymagania względem ranges::begin/ranges::end — to fundamentalna część nowoczesnego STL.

Prosta funkcja: policzyć elementy w dowolnym range

Tak, kontenery często mają .size(). Ale dla range w ogólnym przypadku .size() może nie istnieć. Dlatego zaczniemy od uniwersalnego podejścia: po prostu przejść i policzyć.

#include <cstddef>
#include <ranges>

std::size_t count_elements(const std::ranges::range auto& r) {
    std::size_t n = 0;
    for (const auto& x : r) {
        (void)x;
        ++n;
    }
    return n;
}

Tu ważne są dwie myśli. Po pierwsze, std::ranges::range auto — to bezpośredni kontrakt: „daj mi coś, po czym można iterować”. Po drugie, nie wymagamy niczego zbędnego: ani size(), ani dostępu losowego, ani indeksowania.

Przykład w DataBox: podgląd dla dowolnego range

Zróbmy następny krok: napiszemy funkcję, która wypisuje pierwsze kilka elementów dowolnego range. To przydatne do debugowania DataBox: wczytaliśmy dane — chcemy szybko sprawdzić, czy „wyglądają sensownie”.

#include <iostream>
#include <ranges>

void print_preview(const std::ranges::range auto& r, int limit) {
    int shown = 0;
    for (const auto& x : r) {
        if (shown >= limit) break;
        std::cout << x << ' ';
        ++shown;
    }
    std::cout << '\n';
}

Zauważ: limit pozostawiliśmy jako int. Nie „dopieszczamy” wszystkiego do ideału — ważne jest, by zobaczyć użyteczność std::ranges::range w realnym kodzie.

6. Mini-wersja DataBox z constraints

Teraz stworzymy mały, ale spójny fragment aplikacji, który możecie rozbudować dalej. Będzie czytał N, następnie N liczb, wypisywał podgląd i sumę. Specjalnie wybierzemy typ danych jako parametr szablonu: int lub double. I ograniczymy ten typ standardowymi concepts.

Funkcja czytania: tylko liczby

Chcemy pozwolić tylko na „typy numeryczne”, a na tym etapie kursu można to rozumieć jako „całkowite lub zmiennoprzecinkowe”. Zapiszemy constraint bezpośrednio w deklaracji.

#include <concepts>
#include <iostream>
#include <vector>

template <class T>
requires (std::integral<T> || std::floating_point<T>)
std::vector<T> read_n(std::integral auto n) {
    std::vector<T> v;
    for (int i = 0; i < n; ++i) {
        T x{};
        std::cin >> x;
        v.push_back(x);
    }
    return v;
}

Zauważ elegancką równowagę. Osobno ograniczyliśmy T jako „liczbę”, a n jako „całkowitą”. To dwa różne znaczenia i warto wyrażać je odrębnymi constraints, zamiast mieszać wszystko w jednym wielkim gulaszu.

Suma dla range: przyjmujemy dowolny range liczb

Teraz chcemy sumować cokolwiek, byle było range, a elementy dały się dodawać. Dziś skupiamy się na standardowych concepts, więc nie napiszemy własnego Addable — po prostu ograniczymy typy danych do „liczb” (integral lub floating_point). To uczciwe rozwiązanie dla DataBox.

#include <concepts>
#include <ranges>

template <class R>
requires std::ranges::range<R>
auto sum_range(const R& r) {
    using T = std::ranges::range_value_t<R>;

    static_assert(std::integral<T> || std::floating_point<T>,
                  "sum_range: range elements must be integral or floating_point");

    T sum{};
    for (const auto& x : r) {
        sum += x;
    }
    return sum;
}

Tak, tu trochę „podpatrzyliśmy” std::ranges::range_value_t<R>. Ale semantycznie to po prostu „typ elementu range”. Jeśli myśleć o tym jako „T — typ tego, co jest w środku”, to wystarczy do zrozumienia.

main: łączymy wszystko w jeden scenariusz

Skomponujmy krótki main, który można rzeczywiście uruchomić.

#include <iostream>
#include <vector>

int main() {
    int n = 0;
    std::cin >> n;

    auto data = read_n<double>(n);

    print_preview(data, 5); // np.: 1.1 2.2 3.3 4.4 5.5
    std::cout << sum_range(data) << '\n'; // np.: 16.5
}

To już przypomina małe narzędzie: „wczytaj → pokaż → policz”. I najlepsze: jeśli ktoś spróbuje wywołać read_n<std::string>(n), kompilator zatrzyma to wcześniej, zanim zdążysz powiedzieć „po prostu próbowałem”.

7. Typowe błędy

Błąd nr 1: mylenie std::integral z „to tylko int”.
Takie myślenie zwykle wynika z pierwszych zadań, gdzie wszystko robiliście na int. W prawdziwym C++ jest wiele typów całkowitych i std::integral uczciwie akceptuje je wszystkie. Jeśli w swoim API naprawdę chcesz tylko int, to zapisz int, a nie concept. Concept to kategoria typów.

Błąd nr 2: używanie std::floating_point „na wszelki wypadek”, a potem zdziwienie, że int nie przechodzi.
Klasyczna historia: pisałeś wzór, wygląda „matematycznie”, więc „niech będzie double”. A potem okazuje się, że parametr to indeks, liczba elementów, numer strony — i powinien być całkowity. Concepts wymuszają na tobie uczciwe nazwanie semantyki parametru.

Błąd nr 3: sądzenie, że std::ranges::range oznacza „obiekt ma .size()”.
Range to możliwość otrzymania początku i końca i iterowania. .size() może istnieć, ale nie musi. Pisząc uniwersalny kod, nie mieszaj pojęć. Jeśli potrzebujesz rozmiaru, to inne ograniczenie (zwykle wyrażane przez osobne range-concepts lub dodatkowe wymagania).

Błąd nr 4: zapomnieć dołączyć odpowiednie nagłówki i obwiniać concepts o „kapryśność”.
Dla std::integral i std::floating_point potrzebny jest <concepts>. Dla std::ranges::range potrzebny jest <ranges>. Jeśli zapomnisz include, kompilator uczciwie powie, że nie zna tych nazw — i będzie miał rację. Czasem wygląda to jak „wszystko się zepsuło”, ale w praktyce to zwykła dyscyplina include'ów.

Błąd nr 5: ograniczyć argument, ale nie sprawdzić typu elementów range.
Na przykład napisałeś sum_range(const std::ranges::range auto& r) i wewnątrz robisz sum += x. Range jest, ale elementy mogą być czymkolwiek — nawet std::string. Jeśli z góry wiesz, że chcesz sumować tylko liczby, dodaj sprawdzenie typu elementów (tak jak zrobiliśmy przez static_assert). To nie jest „paranoja”, to przekształcenie wymagań w kontrakt.

1
Zadanie
C++ SELF, poziom 57, lekcja 4
Niedostępne
Filtr parzystości
Filtr parzystości
1
Zadanie
C++ SELF, poziom 57, lekcja 4
Niedostępne
Średnia pomiarów
Średnia pomiarów
1
Zadanie
C++ SELF, poziom 57, lekcja 4
Niedostępne
Licznik znaków
Licznik znaków
1
Zadanie
C++ SELF, poziom 57, lekcja 4
Niedostępne
Suma odczytów
Suma odczytów
1
Ankieta/quiz
CTAD i podstawy concepts, poziom 57, lekcja 4
Niedostępny
CTAD i podstawy concepts
CTAD i podstawy concepts
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION