modern-cpp-features-github
modern-cpp-features-github
github.com/AnthonyCalandra/modern-cpp-features
AnthonyCalandra
C++20/17/14/11
Overview
C++20 includes the following new language features:
Coroutines
Coroutines are special functions that can have their execution suspended
and resumed. To define a coroutine, the co_return , co_await , or
co_yield keywords must be present in the function's body. C++20's
coroutines are stackless; unless optimized out by the compiler, their state is
allocated on the heap.
1/51
generator<int> range(int start, int end) {
while (start < end) {
co_yield start;
start++;
}
task<void> echo(socket s) {
for (;;) {
auto data = co_await s.async_read();
co_await async_write(s, data);
}
task<int> calculate_meaning_of_life() {
co_return 42;
}
2/51
Note: While these examples illustrate how to use coroutines at a basic level,
there is lots more going on when the code is compiled. These examples are
not meant to be complete coverage of C++20's coroutines. Since the
generator and task classes are not provided by the standard library yet,
I used the cppcoro library to compile these examples.
Concepts
3/51
// Forms for function parameters:
// `T` is a constrained type template parameter.
template <my_concept T>
void f(T v);
4/51
template <typename T>
requires my_concept<T> // `requires` clause.
void f(T);
5/51
struct foo {
int foo;
};
struct bar {
using value = int;
value data;
};
struct baz {
using value = int;
value data;
};
6/51
template <typename T>
concept C = requires(T x) {
requires std::same_as<sizeof(x), size_t>;
};
Designated initializers
C-style designated initializer syntax. Any member fields that are not
explicitly listed in the designated initializer list are default-initialized.
struct A {
int x;
int y;
int z = 123;
};
Provides a hint to the optimizer that the labelled statement has a high
probability of being executed.
switch (n) {
case 1:
// ...
break;
7/51
If one of the likely/unlikely attributes appears after the right parenthesis of
an if-statement, it indicates that the branch is likely/unlikely to have its
substatement (body) executed.
struct int_value {
int n = 0;
auto getter_fn() {
// BAD:
// return [=]() { return n; };
// GOOD:
return [=, *this]() { return n; };
}
};
struct foo {
foo() = default;
constexpr foo(int) {}
};
8/51
Virtual functions can now be constexpr and evaluated at compile-time.
constexpr virtual functions can override non- constexpr virtual
functions and vice-versa.
struct X1 {
virtual int f() const = 0;
};
constexpr X4 x4;
x4.f(); // == 4
explicit(bool)
Conditionally select at compile-time whether a constructor is made explicit
or not. explicit(true) is the same as specifying explicit .
struct foo {
// Specify non-integral types (strings, floats, etc.) require
explicit construction.
template <typename T>
explicit(!std::is_integral_v<T>) foo(T) {}
};
foo a = 123; // OK
foo b = "123"; // ERROR: explicit constructor is not a candidate
(explicit specifier evaluates to true)
foo c {"123"}; // OK
Immediate functions
9/51
consteval int sqr(int n) {
return n * n;
}
using enum
After:
10/51
template <typename... Args>
auto f(Args&&... args){
// BY REFERENCE:
return [&...args = std::forward<Args>(args)] {
// ...
};
}
char8_t
Concepts library
Concepts are also provided by the standard library for building more
complicated concepts. Some of these include:
Comparison concepts:
Object concepts:
11/51
Callable concepts:
std::span
12/51
constexpr size_t LENGTH_ELEMENTS = 3;
int* arr = new int[LENGTH_ELEMENTS];
Bit operations
C++20 provides a new <bit> header which provides some bit operations
including popcount.
std::popcount(0u); // 0
std::popcount(1u); // 1
std::popcount(0b1111'0000u); // 4
Math constants
Mathematical constants including PI, Euler's number, etc. defined in the
<numbers> header.
std::numbers::pi; // 3.14159...
std::numbers::e; // 2.71828...
std::is_constant_evaluated
Strings (and string views) now have the starts_with and ends_with
member functions to check if a string starts or ends with the given string.
13/51
Associative containers such as sets and maps have a contains member
function, which can be used instead of the "find and check end of iterator"
idiom.
std::bit_cast
float f = 123.0;
int i = std::bit_cast<int>(f);
std::midpoint
std::midpoint(1, 3); // == 2
std::to_array
Automatic template argument deduction much like how it's done for
functions, but now including class constructors.
14/51
Following the deduction rules of auto , while respecting the non-type
template parameter list of allowable types[*], template arguments can be
deduced from the types of its arguments:
Folding expressions
15/51
auto x1 {1, 2, 3}; // error: not a single element
auto x2 = {1, 2, 3}; // x2 is std::initializer_list<int>
auto x3 {3}; // x3 is int
auto x4 {3.0}; // x4 is double
constexpr lambda
static_assert(addOne(1) == 2);
struct MyObj {
int value {123};
auto getValueCopy() {
return [*this] { return value; };
}
auto getValueRef() {
return [this] { return value; };
}
};
MyObj mo;
auto valueCopy = mo.getValueCopy();
auto valueRef = mo.getValueRef();
mo.value = 321;
valueCopy(); // 123
valueRef(); // 321
Inline variables
16/51
The inline specifier can be applied to variables as well as to functions. A
variable declared inline has the same semantics as a function declared
inline.
It can also be used to declare and define a static member variable, such that
it does not need to be initialized in the source file.
struct S {
S() : id{count++} {}
~S() { count--; }
int id;
static inline int count{0}; // declare and initialize count to 0
within the class
};
Nested namespaces
namespace A {
namespace B {
namespace C {
int i;
}
}
}
namespace A::B::C {
int i;
}
Structured bindings
17/51
using Coordinate = std::pair<int, int>;
Coordinate origin() {
return Coordinate{0, 0};
}
// Destructure by reference.
for (const auto& [key, value] : mapping) {
// Do something with key and value
}
{
std::lock_guard<std::mutex> lk(mx);
if (v.empty()) v.push_back(val);
}
// vs.
if (std::lock_guard<std::mutex> lk(mx); v.empty()) {
v.push_back(val);
}
Foo gadget(args);
switch (auto s = gadget.status()) {
case OK: gadget.zip(); break;
case Bad: throw BadFoo(s.message());
}
// vs.
switch (Foo gadget(args); auto s = gadget.status()) {
case OK: gadget.zip(); break;
case Bad: throw BadFoo(s.message());
}
constexpr if
18/51
template <typename T>
constexpr bool isIntegral() {
if constexpr (std::is_integral<T>::value) {
return true;
} else {
return false;
}
}
static_assert(isIntegral<int>() == true);
static_assert(isIntegral<char>() == true);
static_assert(isIntegral<double>() == false);
struct S {};
static_assert(isIntegral<S>() == false);
char x = u8'x';
switch (n) {
case 1: [[fallthrough]]
// ...
case 2:
// ...
break;
}
19/51
[[nodiscard]] bool do_something() {
return is_success; // true for success, false for failure
}
error_info do_something() {
error_info ei;
// ...
return ei;
}
__has_include
One use case of this would be using two libraries that work the same way,
using the backup/experimental one if the preferred one is not found on the
system.
#ifdef __has_include
# if __has_include(<optional>)
# include <optional>
# define have_optional 1
# elif __has_include(<experimental/optional>)
# include <experimental/optional>
# define have_optional 1
# define experimental_optional
# else
# define have_optional 0
# endif
#endif
20/51
It can also be used to include headers existing under different names or
locations on various platforms, without knowing which platform the
program is running on, OpenGL headers are a good example for this which
are located in OpenGL\ directory on macOS and GL\ on other platforms.
#ifdef __has_include
# if __has_include(<OpenGL/gl.h>)
# include <OpenGL/gl.h>
# include <OpenGL/glu.h>
# elif __has_include(<GL/gl.h>)
# include <GL/gl.h>
# include <GL/glu.h>
# else
# error No suitable OpenGL headers found.
# endif
#endif
std::variant
The class template std::variant represents a type-safe union . An
instance of std::variant at any given time holds a value of one of its
alternative types (it's also possible for it to be valueless).
std::variant<int, double> v{ 12 };
std::get<int>(v); // == 12
std::get<0>(v); // == 12
v = 12.0;
std::get<double>(v); // == 12.0
std::get<1>(v); // == 12.0
std::optional
21/51
std::optional<std::string> create(bool b) {
if (b) {
return "Godzilla";
} else {
return {};
}
}
create(false).value_or("empty"); // == "empty"
create(true).value(); // == "Godzilla"
// optional-returning factory functions are usable as conditions of
while and if
if (auto str = create(true)) {
// ...
}
std::any
std::any x {5};
x.has_value() // == true
std::any_cast<int>(x) // == 5
std::any_cast<int&>(x) = 10;
std::any_cast<int>(x) // == 10
std::string_view
A non-owning reference to a string. Useful for providing an abstraction on
top of strings (e.g. for parsing).
// Regular strings.
std::string_view cppstr {"foo"};
// Wide strings.
std::wstring_view wcstr_v {L"baz"};
// Character arrays.
char array[3] = {'b', 'a', 'r'};
std::string_view array_v(array, std::size(array));
std::invoke
22/51
template <typename Callable>
class Proxy {
Callable c;
public:
Proxy(Callable c): c(c) {}
template <class... Args>
decltype(auto) operator()(Args&&... args) {
// ...
return std::invoke(c, std::forward<Args>(args)...);
}
};
auto add = [](int x, int y) {
return x + y;
};
Proxy<decltype(add)> p {add};
p(1, 2); // == 3
std::apply
std::filesystem
std::byte
23/51
std::byte a {0};
std::byte b {0xFF};
int i = std::to_integer<int>(b); // 0xFF
std::byte c = a & b;
int j = std::to_integer<int>(c); // 0
auto elementFactory() {
std::set<...> s;
s.emplace(...);
return s.extract(s.begin());
}
s2.insert(elementFactory());
Parallel algorithms
24/51
Many of the STL algorithms, such as the copy , find and sort
methods, started to support the parallel execution policies: seq , par and
par_unseq which translate to "sequentially", "parallel" and "parallel
unsequenced".
std::vector<int> longVector;
// Find element using parallel execution policy
auto result1 = std::find(std::execution::par, std::begin(longVector),
std::end(longVector), 2);
// Sort elements using sequential execution policy
auto result2 = std::sort(std::execution::seq, std::begin(longVector),
std::end(longVector));
Binary literals
0b110 // == 6
0b1111'1111 // == 255
C++14 now allows the auto type-specifier in the parameter list, enabling
polymorphic lambdas.
25/51
Because it is now possible to move (or forward) values into a lambda that
could previously be only captured by copy or reference we can now capture
move-only types in a lambda by value. Note that in the below example the
p in the capture-list of task2 on the left-hand-side of = is a new
variable private to the lambda body and does not refer to the original p .
auto p = std::make_unique<int>(1);
Using this reference-captures can have different names than the referenced
variable.
auto x = 1;
auto f = [&r = x, x = x * 10] {
++r;
return r + x;
};
f(); // sets x to 2 and returns 12
Using an auto return type in C++14, the compiler will attempt to deduce
the type for you. With lambdas, you can now deduce its return type using
auto , which makes returning a deduced reference or rvalue reference
possible.
decltype(auto)
26/51
const int x = 0;
auto x1 = x; // int
decltype(auto) x2 = x; // const int
int y = 0;
int& y1 = y;
auto y2 = y1; // int
decltype(auto) y3 = y1; // int&
int&& z = 0;
auto z1 = std::move(z); // int
decltype(auto) z2 = std::move(z); // int&&
int x = 123;
static_assert(std::is_same<const int&, decltype(f(x))>::value == 0);
static_assert(std::is_same<int, decltype(f(x))>::value == 1);
static_assert(std::is_same<const int&, decltype(g(x))>::value == 1);
In C++11, constexpr function bodies could only contain a very limited set
of syntaxes, including (but not limited to): typedef s, using s, and a
single return statement. In C++14, the set of allowable syntaxes expands
greatly to include the most common syntax such as if statements,
multiple return s, loops, etc.
Variable templates
27/51
template<class T>
constexpr T pi = T(3.1415926535897932385);
template<class T>
constexpr T e = T(2.7182818284590452353);
[[deprecated]] attribute
C++14 introduces the [[deprecated]] attribute to indicate that a unit
(function, class, etc.) is discouraged and likely yield compilation warnings. If
a reason is provided, it will be included in the warnings.
[[deprecated]]
void old_method();
[[deprecated("Use new_method instead")]]
void legacy_method();
New user-defined literals for standard library types, including new built-in
literals for chrono and basic_string . These can be constexpr
meaning they can be used at compile-time. Some uses for these literals
include compile-time integer parsing, binary literals, and imaginary number
literals.
28/51
template<typename Array, std::size_t... I>
decltype(auto) a2t_impl(const Array& a,
std::integer_sequence<std::size_t, I...>) {
return std::make_tuple(a[I]...);
}
std::make_unique
Move semantics
29/51
copying some pointers and internal state over to the new vector -- copying
would involve having to copy every single contained element in the vector,
which is expensive and unnecessary if the old vector will soon be destroyed.
See the sections on: rvalue references, special member functions for move
semantics, std::move, std::forward, forwarding references.
Rvalue references
void f(int& x) {}
void f(int&& x) {}
Forwarding references
30/51
T& & becomes T&
T& && becomes T&
T&& & becomes T&
T&& && becomes T&&
int x = 0;
f(0); // T is int, deduces as f(int &&) => f(int&&)
f(x); // T is int&, deduces as f(int& &&) => f(int&)
int& y = x;
f(y); // T is int&, deduces as f(int& &&) => f(int&)
Variadic templates
31/51
An interesting use for this is creating an initializer list from a parameter
pack in order to iterate over variadic function arguments.
sum(1, 2, 3, 4, 5); // 15
sum(1, 2, 3); // 6
sum(1.5, 2.0, 3.7); // 7.2
Initializer lists
return total;
}
Static assertions
Assertions that are evaluated at compile-time.
constexpr int x = 0;
constexpr int y = 1;
static_assert(x == y, "x != y");
auto
auto -typed variables are deduced by the compiler according to the type of
their initializer.
32/51
auto a = 3.14; // double
auto b = 1; // int
auto& c = b; // int&
auto d = { 0 }; // std::initializer_list<int>
auto&& e = 1; // int&&
auto&& f = b; // int&
auto g = new auto(123); // int*
const auto h = 1; // const int
auto i = 1, j = 2, k = 3; // int, int, int
auto l = 1, m = true, n = 1.61; // error -- `l` deduced to be int, `m`
is bool
auto o; // error -- `o` requires initializer
std::vector<int> v = ...;
std::vector<int>::const_iterator cit = v.cbegin();
// vs.
auto cit = v.cbegin();
Functions can also deduce the return type using auto . In C++11, a return
type must be specified either explicitly, or using decltype like so:
The trailing return type in the above example is the declared type (see
section on decltype) of the expression x + y . For example, if x is an
integer and y is a double, decltype(x + y) is a double. Therefore, the
above function will deduce the type depending on what type the expression
x + y yields. Notice that the trailing return type has access to its
parameters, and this when appropriate.
Lambda expressions
[] - captures nothing.
[=] - capture local objects (local variables, parameters) in scope by
value.
[&] - capture local objects (local variables, parameters) in scope by
reference.
[this] - capture this by reference.
[a, &b] - capture objects a by value, b by reference.
33/51
int x = 1;
int x = 1;
decltype
Type aliases
34/51
template <typename T>
using Vec = std::vector<T>;
Vec<int> v; // std::vector<int>
nullptr
C++11 introduces a new null pointer type designed to replace C's NULL
macro. nullptr itself is of type std::nullptr_t and can be implicitly
converted into pointer types, and unlike NULL , not convertible to integral
types except bool .
void foo(int);
void foo(char*);
foo(NULL); // error -- ambiguous
foo(nullptr); // calls foo(char*)
Strongly-typed enums
Attributes
constexpr
35/51
constexpr int square(int x) {
return x * x;
}
int square2(int x) {
return x * x;
}
constexpr values are those that the compiler can evaluate at compile-
time:
struct Complex {
constexpr Complex(double r, double i) : re{r}, im{i} { }
constexpr double real() { return re; }
constexpr double imag() { return im; }
private:
double re;
double im;
};
Delegating constructors
Constructors can now call other constructors in the same class using an
initializer list.
struct Foo {
int foo;
Foo(int foo) : foo{foo} {}
Foo() : Foo(0) {}
};
Foo foo;
foo.foo; // == 0
User-defined literals
User-defined literals allow you to extend the language and add your own
syntax. To create a literal, define a T operator "" X(...) { ... }
function that returns a type T , with a name X . Note that the name of this
36/51
function defines the name of the literal. Any literal names not starting with
an underscore are reserved and won't be invoked. There are rules on what
parameters a user-defined literal function should accept, according to what
type the literal is called on.
struct A {
virtual void foo();
void bar();
};
struct B : A {
void foo() override; // correct -- B::foo overrides A::foo
void bar() override; // error -- A::bar is not virtual
void baz() override; // error -- B::baz does not override A::baz
};
Final specifier
37/51
struct A {
virtual void foo();
};
struct B : A {
virtual void foo() final;
};
struct C : B {
virtual void foo(); // error -- declaration of 'foo' overrides a
'final' function
};
Default functions
struct A {
A() = default;
A(int x) : x{x} {}
int x {1};
};
A a; // a.x == 1
A a2 {123}; // a.x == 123
With inheritance:
struct B {
B() : x{1} {}
int x;
};
struct C : B {
// Calls B::B
C() = default;
};
C c; // c.x == 1
Deleted functions
38/51
class A {
int x;
public:
A(int x) : x{x} {};
A(const A&) = delete;
A& operator=(const A&) = delete;
};
A x {123};
A y = x; // error -- call to deleted copy constructor
y = x; // error -- operator= deleted
The copy constructor and copy assignment operator are called when copies
are made, and with C++11's introduction of move semantics, there is now a
move constructor and move assignment operator for moves.
struct A {
std::string s;
A() : s{"test"} {}
A(const A& o) : s{o.s} {}
A(A&& o) : s{std::move(o.s)} {}
A& operator=(A&& o) {
s = std::move(o.s);
return *this;
}
};
A f(A a) {
return a;
}
39/51
Converting constructors
struct A {
A(int) {}
A(int, int) {}
A(int, int, int) {}
};
Note that the braced list syntax does not allow narrowing:
struct A {
A(int) {}
};
A a(1.1); // OK
A b {1.1}; // Error narrowing conversion from double to int
struct A {
A(int) {}
A(int, int) {}
A(int, int, int) {}
A(std::initializer_list<int>) {}
};
40/51
struct A {
operator bool() const { return true; }
};
struct B {
explicit operator bool() const { return true; }
};
A a;
if (a); // OK calls A::operator bool()
bool ba = a; // OK copy-initialization selects A::operator bool()
B b;
if (b); // OK calls B::operator bool()
bool bb = b; // error copy-initialization does not consider
B::operator bool()
Inline namespaces
All members of an inline namespace are treated as if they were part of its
parent namespace, allowing specialization of functions and easing the
process of versioning. This is a transitive property, if A contains B, which in
turn contains C and both B and C are inline namespaces, C's members can
be used as if they were on A.
namespace Program {
namespace Version1 {
int getVersion() { return 1; }
bool isFirstVersion() { return true; }
}
inline namespace Version2 {
int getVersion() { return 2; }
}
}
41/51
// Default initialization prior to C++11
class Human {
Human() : age{0} {}
private:
unsigned age;
};
// Default initialization on C++11
class Human {
private:
unsigned age {0};
};
C++11 is now able to infer when a series of right angle brackets is used as an
operator or as a closing statement of typedef, without having to add
whitespace.
struct Bar {
// ...
};
struct Foo {
Bar getBar() & { return bar; }
Bar getBar() const& { return bar; }
Bar getBar() && { return std::move(bar); }
private:
Bar bar;
};
Foo foo{};
Bar bar = foo.getBar(); // calls `Bar getBar() &`
42/51
C++11 allows functions and lambdas an alternative syntax for specifying
their return types.
int f() {
return 123;
}
// vs.
auto f() -> int {
return 123;
}
Noexcept specifier
43/51
char32_t and char16_t
C++11 introduces a new way to declare string literals as "raw string literals".
Characters issued from an escape sequence (tabs, line feeds, single
backslashes, etc.) can be inputted raw while preserving formatting. This is
useful, for example, to write literary text, which might contain a lot of quotes
or special formatting. This can make your string literals easier to read and
maintain.
R"delimiter(raw_characters)delimiter"
where:
Example:
std::move
std::move indicates that the object passed to it may have its resources
transferred. Using objects that have been moved from should be used with
care, as they can be left in an unspecified state (see: What can I do with a
moved-from object?).
44/51
template <typename T>
typename remove_reference<T>::type&& move(T&& arg) {
return static_cast<typename remove_reference<T>::type&&>(arg);
}
Transferring std::unique_ptr s:
std::forward
A definition of std::forward :
struct A {
A() = default;
A(const A& o) { std::cout << "copied" << std::endl; }
A(A&& o) { std::cout << "moved" << std::endl; }
};
wrapper(A{}); // moved
A a;
wrapper(a); // copied
wrapper(std::move(a)); // moved
std::thread
45/51
them to finish.
std::vector<std::thread> threadsVector;
threadsVector.emplace_back([]() {
// Lambda function that will be invoked
});
threadsVector.emplace_back(foo, true); // thread will run foo(true)
for (auto& thread : threadsVector) {
thread.join(); // Wait for threads to finish
}
std::to_string
std::to_string(1.2); // == "1.2"
std::to_string(123); // == "123"
Type traits
static_assert(std::is_integral<int>::value);
static_assert(std::is_same<int, int>::value);
static_assert(std::is_same<std::conditional<true, int, double>::type,
int>::value);
Smart pointers
46/51
std::unique_ptr<Foo> p1 { new Foo{} }; // `p1` owns `Foo`
if (p1) {
p1->bar();
}
{
std::unique_ptr<Foo> p2 {std::move(p1)}; // Now `p2` owns `Foo`
f(*p2);
if (p1) {
p1->bar();
}
// `Foo` instance is destroyed when `p1` goes out of scope
void foo(std::shared_ptr<T> t) {
// Do something with `t`...
}
void bar(std::shared_ptr<T> t) {
// Do something with `t`...
}
void baz(std::shared_ptr<T> t) {
// Do something with `t`...
}
std::chrono
The chrono library contains a set of utility functions and types that deal with
durations, clocks, and time points. One use case of this library is
benchmarking code:
47/51
std::chrono::time_point<std::chrono::steady_clock> start, end;
start = std::chrono::steady_clock::now();
// Some computations...
end = std::chrono::steady_clock::now();
Tuples
Tuples are a fixed-size collection of heterogeneous values. Access the
elements of a std::tuple by unpacking using std::tie, or using
std::get .
std::tie
// With tuples...
std::string playerName;
std::tie(std::ignore, playerName, std::ignore) = std::make_tuple(91,
"John Tavares", "NYI");
// With pairs...
std::string yes, no;
std::tie(yes, no) = std::make_pair("yes", "no");
std::array
Unordered containers
48/51
unordered_set
unordered_multiset
unordered_map
unordered_multimap
std::make_shared
std::ref
49/51
// create a container to store reference of objects.
auto val = 99;
auto _ref = std::ref(val);
_ref++;
auto _cref = std::cref(val);
//_cref++; does not compile
std::vector<std::reference_wrapper<int>>vec; // vector<int&>vec does
not compile
vec.push_back(_ref); // vec.push_back(&i) does not compile
cout << val << endl; // prints 100
cout << vec[0] << endl; // prints 100
cout << _cref; // prints 100
Memory model
C++11 introduces a memory model for C++, which means library support
for threading and atomic operations. Some of these operations include (but
aren't limited to) atomic loads/stores, compare-and-swap, atomic flags,
promises, futures, locks, and condition variables.
std::async
int foo() {
/* Do something here, then return the result. */
return 1000;
}
std::begin/end
std::begin and std::end free functions were added to return begin
and end iterators of a container generically. These functions also work with
raw arrays which do not have begin and end member functions.
50/51
template <typename T>
int CountTwos(const T& container) {
return std::count_if(std::begin(container), std::end(container), []
(int item) {
return item == 2;
});
}
Acknowledgements
cppreference - especially useful for finding examples and
documentation of new library features.
C++ Rvalue References Explained - a great introduction I used to
understand rvalue references, perfect forwarding, and move
semantics.
clang and gcc's standards support pages. Also included here are the
proposals for language/library features that I used to help find a
description of, what it's meant to fix, and some examples.
Compiler explorer
Scott Meyers' Effective Modern C++ - highly recommended series of
books!
Jason Turner's C++ Weekly - nice collection of C++-related videos.
What can I do with a moved-from object?
What are some uses of decltype(auto)?
Author
Anthony Calandra
Content Contributors
See: https://github.com/AnthonyCalandra/modern-cpp-
features/graphs/contributors
License
MIT
51/51