Pada tanggal 25 Februari, penulis kursus "Pengembang C ++" di Yandex. Pekerjaan praktis Georgy Osipov berbicara tentang tahap baru bahasa C ++ - Standar C ++ 20. Ceramah ini memberikan gambaran umum dari semua inovasi utama Standar, menjelaskan bagaimana menerapkannya sekarang dan bagaimana mereka dapat berguna.
Saat mempersiapkan webinar, sasarannya adalah memberikan ikhtisar tentang semua fitur utama C ++ 20. Karenanya, webinar tersebut ternyata kaya dan berlangsung selama hampir 2,5 jam. Demi kenyamanan Anda, kami telah membagi teks menjadi enam bagian:
- Modul dan sejarah singkat C ++ .
- Operasi "pesawat luar angkasa" .
- Konsep.
- Rentang.
- Coroutine.
- Fitur pustaka inti dan standar lainnya. Kesimpulan.
Ini adalah bagian ketiga, yang membahas konsep dan batasan dalam C ++ modern.
Konsep
Motivasi
Pemrograman generik adalah keuntungan utama dari C ++. Saya tidak tahu semua bahasa, tetapi saya belum pernah melihat yang seperti itu pada level ini.
Namun, pemrograman generik di C ++ memiliki kerugian besar: kesalahan yang terjadi sangat menyakitkan. Pertimbangkan program sederhana yang mengurutkan vektor. Lihatlah kodenya dan beri tahu saya di mana kesalahannya:
#include <vector>
#include <algorithm>
struct X {
int a;
};
int main() {
std::vector<X> v = { {10}, {9}, {11} };
//
std::sort(v.begin(), v.end());
}
Saya telah mendefinisikan struktur
X
dengan satu bidang
int
, mengisi vektor dengan objek dari struktur itu dan saya mencoba mengurutkannya.
Saya harap Anda membaca contoh dan menemukan bugnya. Saya akan mengumumkan jawabannya: kompilator berpikir bahwa kesalahan ada di ... perpustakaan standar. Output diagnostik kira-kira sepanjang 60 baris dan menunjukkan kesalahan di suatu tempat di dalam file pembantu xutility. Hampir tidak mungkin untuk membaca dan memahami diagnosa, tetapi programmer C ++ melakukannya - bagaimanapun juga, Anda masih perlu menggunakan template.
Kompilator menunjukkan bahwa kesalahan ada di pustaka standar, tetapi ini tidak berarti Anda harus segera menulis ke Komite Standardisasi. Faktanya, kesalahan masih ada di program kami. Hanya saja kompilator tidak cukup pintar untuk mengetahuinya, dan itu mengalami kesalahan saat masuk ke pustaka standar. Mengurai diagnostik ini menyebabkan kesalahan. Tapi ini:
- rumit,
- tidak selalu mungkin pada prinsipnya.
Mari kita rumuskan masalah pertama dari pemrograman generik di C ++: kesalahan saat menggunakan template sama sekali tidak terbaca dan didiagnosis bukan di tempat pembuatannya, tetapi di template.
Masalah lain muncul jika ada kebutuhan untuk menggunakan implementasi fungsi yang berbeda bergantung pada properti tipe argumen. Misalnya, saya ingin menulis fungsi yang memeriksa bahwa dua angka cukup dekat satu sama lain. Untuk bilangan bulat, cukup memeriksa bahwa angkanya sama, untuk bilangan floating point cukup dengan memeriksa bahwa perbedaannya kurang dari beberapa Ξ΅.
Masalahnya dapat diatasi dengan peretasan SFINAE dengan menulis dua fungsi. Penggunaan retas
std::enable_if
... Ini adalah template khusus di pustaka standar yang berisi kesalahan jika kondisinya tidak terpenuhi. Saat membuat instance template, compiler membuang deklarasi dengan error:
#include <type_traits>
template <class T>
T Abs(T x) {
return x >= 0 ? x : -x;
}
//
template<class T>
std::enable_if_t<std::is_floating_point_v<T>, bool>
AreClose(T a, T b) {
return Abs(a - b) < static_cast<T>(0.000001);
}
//
template<class T>
std::enable_if_t<!std::is_floating_point_v<T>, bool>
AreClose(T a, T b) {
return a == b;
}
Di C ++ 17, program semacam itu dapat disederhanakan
if constexpr
, meskipun ini tidak akan berfungsi di semua kasus.
Atau contoh lain: Saya ingin menulis fungsi
Print
yang mencetak apa saja. Jika wadah dilewatkan ke sana, itu akan mencetak semua elemen, jika bukan wadah, itu akan mencetak apa yang dilewatkan. Aku harus menentukan untuk semua kontainer:
vector
,
list
,
set
dan lain-lain. Ini tidak nyaman dan tidak universal.
template<class T>
void Print(std::ostream& out, const std::vector<T>& v) {
for (const auto& elem : v) {
out << elem << std::endl;
}
}
// map, set, list,
// deque, arrayβ¦
template<class T>
void Print(std::ostream& out, const T& v) {
out << v;
}
SFINAE tidak akan membantu di sini lagi. Sebaliknya, itu akan membantu jika Anda mencoba, tetapi Anda harus mencoba banyak, dan kodenya akan berubah menjadi mengerikan.
Masalah kedua dengan pemrograman generik adalah sulit untuk menulis implementasi yang berbeda dari fungsi template yang sama untuk kategori tipe yang berbeda.
Kedua masalah tersebut dapat diselesaikan dengan mudah jika Anda menambahkan hanya satu fitur ke bahasa - untuk menerapkan batasan pada parameter template . Misalnya, mensyaratkan parameter templated menjadi wadah atau objek yang mendukung perbandingan. Inilah konsepnya.
Apa yang dimiliki orang lain
Mari kita lihat bagaimana hal-hal dalam bahasa lain. Satu-satunya yang saya tahu yang memiliki kemiripan adalah Haskell.
class Eq a where
(==) :: a -> a -> Bool
(/=) :: a -> a -> Bool
Ini adalah contoh kelas tipe yang memerlukan dukungan untuk operator yang memancarkan "sama" dan "tidak sama"
Bool
. Di C ++, hal yang sama akan dilakukan seperti ini:
template<typename T>
concept Eq =
requires(T a, T b) {
{ a == b } -> std::convertible_to<bool>;
{ a != b } -> std::convertible_to<bool>;
};
Jika Anda belum terbiasa dengan konsepnya, akan sulit untuk memahami apa yang tertulis. Saya akan menjelaskan semuanya sekarang.
Di Haskell, pembatasan ini diperlukan. Jika Anda tidak mengatakan bahwa akan ada operasi
==
, maka Anda tidak akan dapat menggunakannya. Di C ++, batasannya tidak ketat. Bahkan jika Anda tidak menentukan operasi dalam konsep, itu masih dapat digunakan - lagipula, tidak ada batasan sama sekali sebelumnya, dan standar baru berusaha untuk tidak melanggar kompatibilitas dengan yang sebelumnya.
Contoh
Mari tambahkan kode program di mana Anda baru-baru ini mencari kesalahan:
#include <vector>
#include <algorithm>
#include <concepts>
template<class T>
concept IterToComparable =
requires(T a, T b) {
{*a < *b} -> std::convertible_to<bool>;
};
// IterToComparable class
template<IterToComparable InputIt>
void SortDefaultComparator(InputIt begin, InputIt end) {
std::sort(begin, end);
}
struct X {
int a;
};
int main() {
std::vector<X> v = { {10}, {9}, {11} };
SortDefaultComparator(v.begin(), v.end());
}
Di sini kami telah membuat konsep
IterToComparable
. Ini menunjukkan bahwa tipe
T
adalah iterator, dan itu menunjuk ke nilai yang dapat dibandingkan. Hasil perbandingan adalah sesuatu yang dapat diubah
bool
, misalnya, itu sendiri
bool
. Penjelasan rinci akan diberikan nanti, untuk saat ini Anda tidak perlu mempelajari kode ini.
Ngomong-ngomong, batasannya lemah. Ia tidak mengatakan bahwa suatu tipe harus memenuhi semua properti iterator: misalnya, tidak perlu ditambah. Ini adalah contoh sederhana untuk menunjukkan kemungkinan.
Konsep itu digunakan sebagai pengganti kata
class
atau
typename
dalam konstruksi c
template
. Dulu
template<class InputIt>
, tapi sekarang kata
class
diganti dengan nama konsepnya. Oleh karena itu, parameter
InputIt
harus memenuhi batasan tersebut.
Sekarang, ketika kami mencoba untuk mengkompilasi program ini, kesalahan tidak akan muncul di perpustakaan standar, tetapi sebagaimana mestinya - dalam
main
. Dan kesalahannya bisa dimengerti, karena berisi semua informasi yang diperlukan:
- Apa yang terjadi? Panggilan fungsi dengan batasan yang tidak terpenuhi.
- Kendala mana yang tidak terpenuhi?
IterToComparable<InputIt>
- Mengapa? Ekspresi tersebut
((* a) < (* b))
tidak valid.
Keluaran kompiler dapat dibaca dan membutuhkan 16 baris, bukan 60.
main.cpp: In function 'int main()': main.cpp:24:45: error: **use of function** 'void SortDefaultComparator(InputIt, InputIt) [with InputIt = __gnu_cxx::__normal_iterator<X*, std::vector<X> >]' **with unsatisfied constraints** 24 | SortDefaultComparator(v.begin(), v.end()); | ^ main.cpp:12:6: note: declared here 12 | void SortDefaultComparator(InputIt begin, InputIt end) { | ^~~~~~~~~~~~~~~~~~~~~ main.cpp:12:6: note: constraints not satisfied main.cpp: In instantiation of 'void SortDefaultComparator(InputIt, InputIt) [with InputIt = __gnu_cxx::__normal_iterator<X*, std::vector<X> >]': main.cpp:24:45: required from here main.cpp:6:9: **required for the satisfaction of 'IterToComparable<InputIt>'** [with InputIt = __gnu_cxx::__normal_iterator<X*, std::vector<X, std::allocator<X> > >] main.cpp:7:5: in requirements with 'T a', 'T b' [with T = __gnu_cxx::__normal_iterator<X*, std::vector<X, std::allocator<X> > >] main.cpp:8:13: note: the required **expression '((* a) < (* b))' is invalid**, because 8 | {*a < *b} -> std::convertible_to<bool>; | ~~~^~~~ main.cpp:8:13: error: no match for 'operator<' (operand types are 'X' and 'X')
Mari tambahkan operasi perbandingan yang hilang ke struktur, dan program akan mengkompilasi tanpa kesalahan - konsepnya terpenuhi:
struct X {
auto operator<=>(const X&) const = default;
int a;
};
Demikian pula, Anda dapat meningkatkan contoh kedua, hal
enable_if
. Template ini tidak lagi dibutuhkan. Kami menggunakan konsep standar sebagai gantinya
is_floating_point_v<T>
. Kami mendapatkan dua fungsi: satu untuk bilangan floating point, yang lainnya untuk objek lain:
#include <type_traits>
template <class T>
T Abs(T x) {
return x >= 0 ? x : -x;
}
//
template<class T>
requires(std::is_floating_point_v<T>)
bool AreClose(T a, T b) {
return Abs(a - b) < static_cast<T>(0.000001);
}
//
template<class T>
bool AreClose(T a, T b) {
return a == b;
}
Kami juga memodifikasi fungsi cetak. Jika menelepon
a.begin()
dan
a.end()
berkata, kami menganggap
a
wadah itu.
#include <iostream>
#include <vector>
template<class T>
concept HasBeginEnd =
requires(T a) {
a.begin();
a.end();
};
template<HasBeginEnd T>
void Print(std::ostream& out, const T& v) {
for (const auto& elem : v) {
out << elem << std::endl;
}
}
template<class T>
void Print(std::ostream& out, const T& v) {
out << v;
}
Sekali lagi, ini bukan contoh yang ideal, karena wadah bukan hanya sesuatu dengan
begin
dan
end
, ada lebih banyak persyaratan yang dikenakan padanya. Tapi sudah lumayan.
Cara terbaik adalah menggunakan konsep yang sudah jadi seperti
is_floating_point_v
pada contoh sebelumnya. Untuk analog wadah, pustaka standar juga memiliki konsep -
std::ranges::input_range
. Tapi itu cerita yang sama sekali berbeda.
Teori
Saatnya memahami apa konsepnya. Sebenarnya tidak ada yang rumit di sini:
Konsep adalah nama untuk kendala.
Kami telah mereduksinya menjadi konsep lain, yang definisinya sudah bermakna, tetapi mungkin tampak aneh:
Batasan adalah ekspresi boilerplate.
Secara kasar, kondisi di atas "jadilah iterator" atau "jadilah bilangan titik mengambang" - ini adalah batasannya. Inti sari dari inovasi justru terletak pada keterbatasan, dan konsepnya hanyalah cara untuk merujuknya.
Batasan paling sederhana adalah ini
true
. Tipe apa pun cocok untuknya.
template<class T> concept C1 = true;
Operasi Boolean dan kombinasi batasan lain tersedia untuk batasan:
template <class T>
concept Integral = std::is_integral<T>::value;
template <class T>
concept SignedIntegral = Integral<T> &&
std::is_signed<T>::value;
template <class T>
concept UnsignedIntegral = Integral<T> &&
!SignedIntegral<T>;
Anda dapat menggunakan ekspresi dalam batasan dan bahkan memanggil fungsi. Tapi fungsinya harus constexpr - mereka dihitung pada waktu kompilasi:
template<typename T>
constexpr bool get_value() { return T::value; }
template<typename T>
requires (sizeof(T) > 1 && get_value<T>())
void f(T); // #1
void f(int); // #2
void g() {
f('A'); // #2.
}
Dan daftar kemungkinan tidak berakhir di situ.
Ada fitur hebat untuk kendala: memeriksa kebenaran ekspresi - yang dikompilasi tanpa kesalahan. Lihat batasannya
Addable
. Itu ditulis dalam tanda kurung
a + b
. Kondisi batasan terpenuhi ketika nilai
a
dan
b
tipe
T
mengizinkan rekaman seperti itu, yaitu, ia
T
memiliki operasi penambahan tertentu:
template<class T>
concept Addable =
requires (T a, T b) {
a + b;
};
Contoh yang lebih kompleks adalah memanggil fungsi
swap
dan
forward
. Batasan akan dijalankan ketika kode ini dikompilasi tanpa kesalahan:
template<class T, class U = T>
concept Swappable = requires(T&& t, U&& u) {
swap(std::forward<T>(t), std::forward<U>(u));
swap(std::forward<U>(u), std::forward<T>(t));
};
Jenis kendala lainnya adalah jenis validasi:
template<class T> using Ref = T&;
template<class T> concept C =
requires {
typename T::inner;
typename S<T>;
typename Ref<T>;
};
Batasan mungkin memerlukan tidak hanya kebenaran ekspresi, tetapi juga jenis nilainya sesuai dengan sesuatu. Di sini kami menulis:
- ekspresi dalam kurung kurawal,
->,
- batasan lain.
template<class T> concept C1 =
requires(T x) {
{x + 1} -> std::same_as<int>;
};
Batasan dalam kasus ini -
same_as<int>
Artinya, jenis ekspresi
x + 1
harus persis
int
.
Perhatikan bahwa panah diikuti oleh batasan, bukan tipe itu sendiri. Lihat contoh lain dari konsep tersebut:
template<class T> concept C2 =
requires(T x) {
{*x} -> std::convertible_to<typename T::inner>;
{x * 1} -> std::convertible_to<T>;
};
Ini memiliki dua batasan. Yang pertama menunjukkan bahwa:
- ekspresinya
*x
benar; - jenisnya
T::inner
benar; - jenis
*x
diubah menjadiT::inner.
Ada tiga persyaratan dalam satu baris. Yang kedua menunjukkan bahwa:
- ekspresi
x * 1
sintaksis benar; - hasilnya diubah menjadi
T
.
Batasan apa pun dapat dibentuk dengan menggunakan metode di atas. Mereka sangat menyenangkan dan menyenangkan, tetapi Anda akan segera merasa bosan dan lupa jika Anda tidak dapat menggunakannya. Dan Anda dapat menggunakan batasan dan konsep untuk apa pun yang mendukung template. Tentu saja, kegunaan utamanya adalah fungsi dan kelas.
Jadi, kami telah menemukan cara menulis batasan , sekarang saya akan memberi tahu Anda di mana Anda dapat menulisnya .
Batasan fungsi dapat ditulis di tiga tempat berbeda:
// class typename .
// .
template<Incrementable T>
void f(T arg);
// requires.
// .
// .
template<class T>
requires Incrementable<T>
void f(T arg);
template<class T>
void f(T arg) requires Incrementable<T>;
Dan ada cara keempat, yang terlihat cukup ajaib:
void f(Incrementable auto arg);
Template implisit digunakan di sini. Hingga C ++ 20, mereka hanya tersedia dalam lambda. Anda sekarang dapat digunakan
auto
dalam setiap fungsi tanda tangan:
void f(auto arg)
. Selain itu,
auto
nama konsep diperbolehkan sebelum ini , seperti pada contoh. Omong-omong, templat eksplisit sekarang tersedia di lambda, tetapi lebih banyak lagi nanti.
Perbedaan penting: ketika kita menulis
requires
, kita dapat menuliskan batasan apa pun, dan dalam kasus lain, hanya nama konsepnya.
Ada lebih sedikit kemungkinan untuk sebuah kelas - hanya dua cara. Tapi ini cukup:
template<Incrementable T>
class X {};
template<class T>
requires Incrementable<T>
class Y {};
Anton Polukhin, yang membantu penyusunan artikel ini, memperhatikan bahwa kata
requires
dapat digunakan tidak hanya saat mendeklarasikan fungsi, kelas, dan konsep, tetapi juga tepat di badan suatu fungsi atau metode. Misalnya, ini berguna jika Anda menulis fungsi yang mengisi wadah dari tipe yang sebelumnya tidak dikenal:
template<class T>
void ReadAndFill(T& container, int size) {
if constexpr (requires {container.reserve(size); }) {
container.reserve(size);
}
//
}
Fungsi ini akan bekerja sama baiknya dengan keduanya
vector
, dan dengan
list
, dan untuk yang pertama, metode yang diperlukan dalam kasusnya akan dipanggil
reserve
.
Berguna
requires
untuk
static_assert
. Dengan cara ini, Anda dapat memeriksa pemenuhan tidak hanya kondisi biasa, tetapi juga kebenaran kode arbitrer, keberadaan metode dan operasi dalam tipe.
Menariknya, sebuah konsep dapat memiliki beberapa parameter template. Saat menggunakan konsep, Anda perlu menentukan semuanya kecuali satu - yang kami periksa batasannya.
template<class T, class U>
concept Derived = std::is_base_of<U, T>::value;
template<Derived<Other> X>
void f(X arg);
Konsep tersebut memiliki
Derived
dua parameter template. Dalam deklarasi,
f
saya menunjukkan salah satunya, dan yang kedua - kelas
X
, yang dicentang. Audiens ditanya parameter mana yang saya tunjukkan:
T
atau
U
; apakah itu berhasil
Derived<Other, X>
atau
Derived<X, Other>
?
Jawabannya tidak jelas: memang
Derived<X, Other>
. Saat menentukan parameter
Other
, kami menentukan parameter kerangka kedua. Hasil pemungutan suara berbeda:
- jawaban yang benar - 8 (61,54%);
- jawaban yang salah - 5 (38.46%).
Saat menentukan parameter konsep, Anda perlu menentukan semuanya kecuali yang pertama, dan yang pertama akan diperiksa. Saya sudah lama memikirkan mengapa Komite membuat keputusan seperti itu, dan saya menyarankan Anda untuk berpikir juga. Tuliskan ide Anda di kolom komentar.
Jadi, saya memberi tahu Anda cara mendefinisikan konsep baru, tetapi ini tidak selalu diperlukan - sudah ada banyak konsep di perpustakaan standar. Slide ini menunjukkan konsep yang ditemukan di file header <concepts>.
Bukan itu saja: ada konsep untuk menguji berbagai jenis iterator di <iterator>, <ranges>, dan pustaka lainnya.
Status
"Konsep" ada di mana-mana, tetapi belum sepenuhnya di Visual Studio:
- GCC. Didukung dengan baik sejak versi 10;
- Dentang. Dukungan penuh di versi 10;
- Studio visual. Didukung oleh VS 2019, tetapi tidak sepenuhnya membutuhkan implementasi.
Kesimpulan
Selama siaran, kami bertanya kepada penonton apakah mereka menyukai fitur ini. Hasil survei:
- Fitur super - 50 (92,59%)
- Jadi fitur - 0 (0,00%)
- Tidak Jelas - 4 (7,41%)
Mayoritas dari mereka yang memberikan suara menghargai konsep tersebut. Menurut saya ini juga fitur yang keren. Terima kasih kepada Panitia!
Pembaca Habr, serta pendengar webinar akan diberikan kesempatan untuk mengevaluasi inovasi tersebut.