Series overview
4 workshops:
- Fundamentals
- Templates
- Move semantics & rvalue references
- Metaprogramming
Agenda
- Lecture: Move semantics (practical)
- Lecture: rvalue references (theoretical)
- Lecture: Perfect forwarding (mixed)
- Exercises: 1 quiz, 2 exercises.
Before we start
Get latest version of Visual Studio 2017!
Contact information:
143042@nhtv.nl | |
Slack | Caspar143042 |
Discord | Aidiakapi#2177 |
Please give me feedback! https://goo.gl/forms/zVXOH97o1iyB4qIj1
Move Semantics
What is it, why should you care?
When copying makes no sense
Some objects should logically not be copied.
12345678910111213⋮14struct Player { /* stuff here */ };struct Game{ std::unique_ptr<Player> player;};static void example(){ auto player = std::make_unique<Player>(); Game game{ std::move(player) }; What happens? Moving a unique_ptr instead transfers ownership. player == nullptr // error C2280: 'std::unique_ptr<Player,std::default_delete<_Ty>>::unique_ptr // (const std::unique_ptr<_Ty,std::default_delete<_Ty>> &)' // : attempting to reference a deleted function Signature: T(T const&) What is that called? Trying to call the copy constructor.}
Copying a std::unique_ptr
is not allowed. Why?
It is a unique owner of the resource. Copying would imply at least 2 owners.
A std::unique_ptr
can be moved instead!
When it makes no sense to copy
Some objects you don't want to copy.
1234567891011⋮12struct Bullet { /* bullet data here */ };struct Game{ std::vector<Bullet> bullets;};static void example(){ std::vector<Bullet> bullets; // Inserts 1000 bullets here Game game{ std::move(bullets) }; What happens to bullets? 1000 bullets get copied. Moving the vector is basically a pointer-swap.}
Moving here avoids an unnecessary copy.
The state of bullets is unspecified but valid.[SO]
Motivation
Reasons for moving:
-
When a type logically cannot be copied.
std::unique_ptr
std::unique_lock
std::basic_istream
&std::basic_ostream
- Objects living in VRAM.
- Network sockets.
-
Avoid copies to improve performance.
- Any copyable resource.
- Collections.
Copy semantics
12345678⋮910111213141516171819202122232425⋮262728293031323334⋮3536373839404142434445464748495051⋮5253545556575859⋮60616263646566⋮67686970717273747576777879808182⋮83848586878889⋮90919293949596979899100101102103⋮class Surface{public: Surface() noexcept : width_ { 0 }, height_{ 0 }, buffer_{ nullptr } { } Surface(int32_t const width, int32_t const height) { if (width <= 0 || height <= 0) { width_ = height_ = 0; buffer_ = nullptr; return; } size_t const byte_count = width * height * sizeof(Pixel); width_ = width; height_ = height; buffer_ = static_cast<Pixel*>(_aligned_malloc(byte_count, 16)); Compiler specific! What if the allocation fails? if (buffer_ == nullptr) { throw std::bad_alloc{}; } std::fill_n(buffer_, width_ * height_, 0); } Surface(Surface const& rhs) : Surface{ rhs.width_, rhs.height_ } { if (buffer_ != nullptr) { std::copy_n(rhs.buffer_, width_ * height_, buffer_); } } Surface& operator=(Surface const& rhs) { auto copy = rhs; swap(copy); return *this; } Surface(Surface&& rhs) noexcept : Surface{} { swap(rhs); } Surface& operator=(Surface&& rhs) noexcept { auto move = std::move(rhs); swap(move); return *this; } ~Surface() noexcept { if (buffer_ != nullptr) { _aligned_free(buffer_); } } void swap(Surface& rhs) noexcept { std::swap(buffer_, rhs.buffer_); std::swap(width_ , rhs.width_ ); std::swap(height_, rhs.height_); }private: int32_t width_; int32_t height_; Pixel* buffer_;};Correct?The copy constructor/assignment!inline void swap(Surface& lhs, Surface& rhs) noexceptCorrect?Yes, let's use it.{ lhs.swap(rhs);}struct GameObject { Surface image; };static void example_copy(){ Surface image{ 128, 256 }; GameObject player{ image }; What happens? 32768 pixels get copied}Fixes?struct GameObjectUptr { std::unique_ptr<Surface> image; };static void example_uptr(){ auto image = std::make_unique<Surface>(128, 256); GameObjectUptr player{ std::move(image) };}Problem?1) Requires breaking change on API.2) Extra allocation.3) Every access to image requires extra indirection.static void example_move(){ Surface image{ 128, 256 }; GameObject player{ std::move(image) }; What happens? 32768 pixels get copied but that can be changed (suspense)}static void example_move_assign(){ GameObject player; Surface image{ 128, 256 }; player.image = std::move(image);}
Move semantics
12345678910111213141516171819202122232425262728293031323334353637383940⋮414243444546474849⋮505152535455565758596061626364656667686970⋮71727374757677⋮7879808182838485868788899091⋮929394959697⋮9899100101102⋮103class Surface{public: Surface() noexcept : width_ { 0 }, height_{ 0 }, buffer_{ nullptr } { } Surface(int32_t const width, int32_t const height) { if (width <= 0 || height <= 0) { width_ = height_ = 0; buffer_ = nullptr; return; } size_t const byte_count = width * height * sizeof(Pixel); width_ = width; height_ = height; buffer_ = static_cast<Pixel*>(_aligned_malloc(byte_count, 16)); if (buffer_ == nullptr) { throw std::bad_alloc{}; } std::fill_n(buffer_, width_ * height_, 0); } Surface(Surface const& rhs) : Surface{ rhs.width_, rhs.height_ } { if (buffer_ != nullptr) { std::copy_n(rhs.buffer_, width_ * height_, buffer_); } } Surface& operator=(Surface const& rhs) { auto copy = rhs; swap(copy); return *this; } Surface(Surface&& rhs) noexcept : Move constructor, signature: T(T&&) noexcept Important noexcept! rvalue reference Surface{} { *this === player.image (is empty) rhs === local variable 'image' (correct data) swap(rhs); *this === player.image (correct data) rhs === local variable 'image' (is empty) } Surface& operator=(Surface&& rhs) noexcept Move assignment, signature: T& operator=(T&&) noexcept { auto move = std::move(rhs); Is std::move here necessary? Absolutely! Without it, it would call the copy constructor! swap(move); return *this; } ~Surface() noexcept { if (buffer_ != nullptr) { _aligned_free(buffer_); } } void swap(Surface& rhs) noexcept { std::swap(buffer_, rhs.buffer_); std::swap(width_ , rhs.width_ ); std::swap(height_, rhs.height_); }private: int32_t width_; int32_t height_; Pixel* buffer_;};inline void swap(Surface& lhs, Surface& rhs) noexcept{ lhs.swap(rhs);}struct GameObject { Surface image; };static void example_copy(){ Surface image{ 128, 256 }; GameObject player{ image };}struct GameObjectUptr { std::unique_ptr<Surface> image; };static void example_uptr(){ auto image = std::make_unique<Surface>(128, 256); GameObjectUptr player{ std::move(image) };}static void example_move(){ Surface image{ 128, 256 }; GameObject player{ std::move(image) }; What would you like to happen? 1) Player must have the correct image data. 2) No copying the image data. 3) 0 allocations, 0 frees. 4) 'image' should now be "empty". How to make it happen?}static void example_move_assign(){ GameObject player; Surface image{ 128, 256 }; player.image = std::move(image); What does this do? Calls the copy assignment operator... unless we implement a move assignment operator.}
Default operations
Info about default operations:
- 6 default operations: default constructor, copy constructor, copy assignment, move constructor, move assignment, destructor.
- Implicitly or explicit generated. (
= default
) - Default implementation applies it to each field.
noexcept
if operation on all fields isnoexcept
.
Default implementation is similar but not identical to this:
1234⋮567891011121314151617181920212223242526⋮27282930class DefaultOperations{public: DefaultOperations() noexcept {} // No value initialization DefaultOperations(DefaultOperations const& rhs) : a{ rhs.a }, b{ rhs.b } { } DefaultOperations& operator=(DefaultOperations const& rhs) { a = rhs.a; b = rhs.b; return *this; } DefaultOperations(DefaultOperations&& rhs) noexcept : a{ std::move(rhs.a) }, b{ std::move(rhs.b) } { } DefaultOperations& operator=(DefaultOperations&& rhs) noexcept { a = std::move(rhs.a); b = std::move(rhs.b); return *this; } ~DefaultOperations() noexcept {}private: int a; std::vector<int> b;};
The default implementation may be trivial.[SO]
Summary
What we covered:
- Non-copyable objects might be moveable.
- Moving is generally faster.
- Objects moved from should be in a valid state.
- In standard library, state of moved from objects is unspecified.
- For specific types (like
std::unique_ptr
), the moved-from state is specified. - You can implement your own move constructor and assignment.
- Default implementation is applying that same operation to each field.
References
&
and &&
<== these things
So what are &
and &&
?
T&&
is an rvalue reference to T.T&
is an lvalue reference to T.- References are just an alias to an object.
- References to references don't exist.
The next question is, what are lvalues and rvalues?
These are known expression categories or value categories.
What are expression categories?
- Category of an expression. What is an expression?
An expression is a sequence of operators and their operands, that specifies a computation.
[C++ Ref]-
Expressions include:
- Any operator (including call, and member access).
- Casts.
- Some keywords (new, delete, sizeof, ...).
- Names and constants.
- Each expression is either an lvalue or rvalue.
- There are 2 types of rvalues: xvalue and prvalue.
History of the terminology
Historically lvalue meant left value, rvalue meant right value.
12345678910⋮11121314151617181920⋮21int sum(int a, int b) { a = a + b; return a;}int mul(int a, int b){ return a * b;}static void example(){ int x; x = 5; 7 = x; What does this mean? error C2106: '=': left operand must be l-value x is an lvalue 5/7 is an rvalue int const y = sum(7, x); Type of '7' === type of 'a' === int, the category is different. y = 5; error C3892: 'y': you cannot assign to a variable that is const sum = mul; error C2659: '=': function as left operand}
Left and right values are not, and never were accurate.
Distinguising expression categories
Rules of thumb: Any expression is an lvalue, if ...
- ...you can take the address of it.
- ...it has a name.
- ...it has type
T&
.
12345678⋮91011121314151617181920⋮21222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103⋮class Surface{public: Surface() noexcept : width_ { 0 }, height_{ 0 }, buffer_{ nullptr } { } Surface(int32_t const width, int32_t const height) { if (width <= 0 || height <= 0) lvalue lvalue rvalue rvalue rvalue { width_ = height_ = 0; lvalue lvalue buffer_ = nullptr; lvalue rvalue return; } size_t const byte_count = width * height * sizeof(Pixel); width_ = width; height_ = height; buffer_ = static_cast<Pixel*>(_aligned_malloc(byte_count, 16)); if (buffer_ == nullptr) rvalue { throw std::bad_alloc{}; rvalue Is throw even an expression? Yes it is! Its type: void rvalue } std::fill_n(buffer_, width_ * height_, 0); lvalue rvalue } Surface(Surface const& rhs) : Surface{ rhs.width_, rhs.height_ } { if (buffer_ != nullptr) { std::copy_n(rhs.buffer_, width_ * height_, buffer_); } } Surface& operator=(Surface const& rhs) { auto copy = rhs; swap(copy); return *this; } Surface(Surface&& rhs) noexcept : Surface{} { swap(rhs); } Surface& operator=(Surface&& rhs) noexcept { auto move = std::move(rhs); swap(move); return *this; } ~Surface() noexcept { if (buffer_ != nullptr) { _aligned_free(buffer_); } } void swap(Surface& rhs) noexcept { std::swap(buffer_, rhs.buffer_); std::swap(width_ , rhs.width_ ); std::swap(height_, rhs.height_); }private: int32_t width_; int32_t height_; Pixel* buffer_;};inline void swap(Surface& lhs, Surface& rhs) noexcept{ lhs.swap(rhs);}struct GameObject { Surface image; };static void example_copy(){ Surface image{ 128, 256 }; GameObject player{ image };}struct GameObjectUptr { std::unique_ptr<Surface> image; };static void example_uptr(){ auto image = std::make_unique<Surface>(128, 256); GameObjectUptr player{ std::move(image) };}static void example_move(){ Surface image{ 128, 256 }; GameObject player{ std::move(image) };}static void example_move_assign(){ GameObject player; Surface image{ 128, 256 }; player.image = std::move(image);}
T&&
rvalue references
A parameter or variable of type T&&
can reference an rvalue.
12345678910111213141516⋮17void by_value (int x) { ++x; }void by_lvalue_ref(int& x) { ++x; }void by_rvalue_ref(int&& x) { ++x; }static void example(){ int number = 3; by_value (number); // copies ==> number == 3 Does what? Copies number, still number == 3 by_lvalue_ref(number); // reference ==> number == 4 Does what? References number, now number == 4 by_rvalue_ref(number); // error Does what? error: cannot bind rvalue reference of type 'int&&' to lvalue of type 'int' by_value (int{7}); // copies ==> temporary object == 7 Does what? Copies temporary object, temporary object == 7 by_lvalue_ref(int{7}); // error Does what? error: cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int' by_rvalue_ref(int{7}); // reference ==> temporary object == 8 Does what? References temporary object, temporary object == 8 by_rvalue_ref(std::move(number)); // reference ==> number == 5 References number, number == 5 What does std::move actually do? Hint: There are 2 copies and 0 moves in this function. std::move turns any expression into an rvalue}
An rvalue reference is still a reference!
References alias objects.
Function overloading
12345678910111213141516171819202122232425262728⋮29inline void fn(T& ) { puts("l"); }inline void fn(T&&) { puts("r"); }static void example(){ T val{}; T& lvr = val; T&& rvr = T{}; fn(val); // l fn(lvr); // l fn(rvr); // l fn(T{}); // r puts(""); fn(std::move(val)); // r fn(std::move(lvr)); // r fn(std::move(rvr)); // r fn(std::move(T{})); // r puts(""); fn(static_cast<decltype(val)>(val)); // r, decltype(val) === T Copies val! fn(static_cast<decltype(lvr)>(lvr)); // l, decltype(lvr) === T& fn(static_cast<decltype(rvr)>(rvr)); // r, decltype(rvr) === T&& fn(static_cast<decltype(T{})>(T{})); // r, decltype(T{}) === T puts(""); fn(static_cast<decltype(val)&&>(val)); // r, decltype(val) === T decltype(val)&& === T&& fn(static_cast<decltype(lvr)&&>(lvr)); // l, decltype(lvr) === T& decltype(lvr)&& === T& fn(static_cast<decltype(rvr)&&>(rvr)); // r, decltype(rvr) === T&& decltype(rvr)&& === T&& fn(static_cast<decltype(T{})&&>(T{})); // r, decltype(T{}) === T decltype(T{})&& === T&& }
Function overloading (cont)
Summarizing
- Reminder: A variable or parameter of
T&&
is still an lvalue. - Overloading between
T&
andT&&
is done based on expression category. -
References to references collapse.
123456⋮using L = int&;using R = int&&;using LL = L&; // int & & ==> int &using LR = L&&; // int & && ==> int &using RL = R&; // int && & ==> int &using RR = R&&; // int && && ==> int &&
decltype(name)
gives the declared type of name.- Binding or casting an lvalue or xvalue to the non-reference type, will construct it.
- But casting a prvalue to the non-reference type will not.
prvalue and xvalue
Commonly used xvalues come from:
-
Casting to an rvalue reference:
1static_cast<int&&>(5)
-
Function call that returns an rvalue reference:
12int&& test();test()
Most other rvalues are prvalues.[C++ Ref]
Question: When would you return an rvalue reference from a function?
When you want to return both a reference and an rvalue.
Reminder: An rvalue reference is still a reference!
1int&& example() { return 5; } // BAD! Dangling reference
decltype
returns
If decltype
's argument is in parentheses, it also uses the expression category.
123⋮456789101112131415161718192021222324⋮struct IntWrapper { int value; };float sum(int const a, float const& b) { return a + b; }int x = 5;int& y = x;int&& z = 5;decltype( x ) // int decltype((x)) // int& decltype( 5 ) // int decltype((5)) // int decltype( y ) // int& decltype((y)) // int& decltype( z ) // int&& decltype((z)) // int& decltype( IntWrapper{5}.value ) // int decltype((IntWrapper{5}.value)) // int&& decltype( sum ) // float (int, float const&) decltype((sum)) // float(&)(int, float const&)
Perfect forwarding
To wrap functions correctly.
Creating components
123456789101112⋮13141516171819202122⋮232425262728293031323334⋮353637383940414243444546474849505152535455⋮5657⋮58596061⋮626364656667686970717273747576777879808182838485⋮8687888990⋮919293949596979899100101102⋮103104⋮105106107108⋮109class Component{public: virtual void update(float delta_seconds) = 0; virtual ~Component() noexcept = default;};struct Particle{ float x, y; float vx, vy;};class ParticleSystem : public Component{ void update(float const delta_seconds) override { for (auto& particle : particles) { particle.x += particle.vx * delta_seconds; particle.y += particle.vy * delta_seconds; } }public: std::vector<Particle> particles;};class GameObject{public: template <typename T> T& create_component() { components_.push_back(std::make_unique<T>()); return static_cast<T&>(*components_.back()); } template <typename T, typename P> T& create_component(P param) { components_.push_back(std::make_unique<T>(std::move(param))); return static_cast<T&>(*components_.back()); } template <typename T, typename P> T& create_component(P const& param) { components_.push_back(std::make_unique<T>(param)); What if we change it to std::move(param)? Would be xvalue of type: P const&& Cannot call: T(P&&). Can call: T(P const&). Copies value. return static_cast<T&>(*components_.back()); } template <typename T, typename P> T& create_component(P&& param) ParticleSystem& param ONLY with rvalue reference to deduced type, special name "forwarding reference". Category of 'param'? Category: lvalue, type: ParticleSystem&&. { components_.push_back(std::make_unique<T>(std::forward<P>(param))); What should wrap 'param'? Hint: param's type is either ParticleSystem& or ParticleSystem&& static_cast<decltype(param)>(param) would work Or use std::forward<P>. return static_cast<T&>(*components_.back()); } template <typename T, typename... P> T& create_component(P&&... params) { static_assert(std::is_base_of_v<Component, T>, "T must be a Component"); components_.push_back(std::make_unique<T>(std::forward<P>(params)...)); return static_cast<T&>(*components_.back()); }private: std::vector<std::unique_ptr<Component>> components_;};void create_fire_particles(ParticleSystem& ps){ ps.particles.clear(); std::default_random_engine rnd; for (int i = 0; i < 100; i++) { ps.particles.push_back( { /* x */std::uniform_real_distribution<float>{ -0.2f, 0.2f }(rnd), /* y */std::uniform_real_distribution<float>{ -0.2f, 0.2f }(rnd), /* vx */std::uniform_real_distribution<float>{ -0.2f, 0.2f }(rnd), /* vy */std::uniform_real_distribution<float>{ 2.0f, 8.0f }(rnd), }); }}void example_basic(){ GameObject fire; auto& particle_system = fire.create_component<ParticleSystem>(); How to write create_component? create_fire_particles(particle_system); Fills the particle system with data.}void example_multiple(){ ParticleSystem ps; create_fire_particles(ps); GameObject fire; fire.create_component<ParticleSystem>(ps); What happens with ps? (Copies, moves, etc.) 1st copy, 2nd move (or copy if non-moveable). Ideally always 1 copy. What happens? 1st reference (no-op), 2nd copy. Does this compile? Yes! P === ParticleSystem& ==> P&& === ParticleSystem& What happens? 1 copy, identical to calling ParticleSystem{ps} GameObject another_fire; another_fire.create_component<ParticleSystem>(std::move(ps)); What happens? 1st reference (no-op), 2nd copy. What happens?}void example_error(){ GameObject fire; fire.create_component<int>(5); example.cpp(62): error C2664: 'void std::vector<std::unique_ptr<Component,std::default_delete<_Ty>>,std::allocator<std::unique_ptr<_Ty,std::default_delete<_Ty>>>>::push_back(std::unique_ptr<_Ty,std::default_delete<_Ty>> &&)': cannot convert argument 1 from 'std::unique_ptr<T,std::default_delete<_Ty>>' to 'const std::unique_ptr<Component,std::default_delete<_Ty>> &' example.cpp(61): error C2338: T must be a Component [with T=int]}
Creating components (summary)
Takeaways:
T&&
is forwarding reference iifT
is deduced type.- No cv-qualifiers allowed on forwarding reference!
- If argument is an lvalue, the compiler adds lvalue reference to the deduced type.[C++ Ref]
- Combine with
std::forward<T>
. - Universal reference is synonymous with forwarding reference.
- Use
static_assert
to make your error messages more readable.
Return values
1234⋮56789101112131415161718192021222324252627⋮28#define IS_LVALUE(EXPR) ( std::is_lvalue_reference_v<decltype((EXPR))>)#define IS_XVALUE(EXPR) ( std::is_rvalue_reference_v<decltype((EXPR))>)#define IS_PRVALUE(EXPR) (!std::is_reference_v <decltype((EXPR))>)template <typename Fun, typename... P>decltype(auto) passthrough(Fun fn, P&&... v) { return fn(std::forward<P>(v)...);}What return type?int idt_integer(int& v) { return v; }int& lvr_integer(int& v) { return v; }int&& rvr_integer(int& v) { return static_cast<int&&>(v); }static void example(){ int x{ 5 }; static_assert(IS_PRVALUE( idt_integer(x) )); static_assert( IS_LVALUE( lvr_integer(x) )); static_assert( IS_XVALUE( rvr_integer(x) )); static_assert( IS_XVALUE( std::move<int&>(x) )); static_assert(IS_PRVALUE( passthrough(idt_integer, x) )); static_assert( IS_LVALUE( passthrough(lvr_integer, x) )); static_assert( IS_XVALUE( passthrough(rvr_integer, x) )); static_assert( IS_XVALUE( passthrough(std::move<int&>, x) )); How to make IS_LVALUE, IS_XVALUE, IS_PRVALUE?}
When is this useful? Rarely, but if you need it, it's invaluable.
Performance
1234567891011121314⋮std::vector<int> generate_numbers(){ return { 0, 1, 2, 3, 4, 5 };}std::vector<int> passthrough(){ return generate_numbers();}void example(){ auto numbers = passthrough();}
How often does the vector get copied?
0 copies (and 0 moves).
Guaranteed by standard!
Implementation of std::move
and std::forward
123456789101112⋮131415161718⋮template <typename T> constexpr std::remove_reference_t<T>&& move(T&& v) noexcept Forwarding reference{ return static_cast<std::remove_reference_t<T>&&>(v); Why remove reference from T? T& && would otherwise collapse to T&}template <typename T>constexpr T&& forward(std::remove_reference_t<T>& v) noexcept Called if argument is lvalue. (Almost always.){ return static_cast<T&&>(v); If T ==> T&&. If T& ==> T&. If T&& ==> T&&.}template <typename T>constexpr T&& forward(std::remove_reference_t<T>&& v) noexceptCalled if argument is rvalue. (Almost never.){ static_assert(!std::is_lvalue_reference_v<T>, "incorrect template parameter to T"); Value being forwarded is an rvalue. Cannot convert rvalue to lvalue. return static_cast<T&&>(v);}
No compiler magic, just plain code.
Summary
Summary:
- Perfect forwarding to pass parameters/return values to/from other functions.
- Be aware of forwarding references (
T&&
whereT
is deduced). - Return by value, it gets optimized.
Guidelines:
Exercises
Practice makes perfect.
Expression Category quiz!
Goal: Look at an expression, and determine the expression category.
- 15 questions.
- Answers immediately visible after submitting.
Link: https://goo.gl/forms/bE1bXpzyU5TjiOCc2
Estimated duration: 2-5 minutes.
Exercise 1 - Move operations
Goal: Write copy/move constructor/assignment for BitArray
class.
Steps:
- Add declarations in exercise.hpp at line 9.
- Add definitions in exercise.cpp at line 36.
- Make the 16 unit tests pass.
Uses TestAllocator::allocate
instead of new unsigned char[]
.
Uses TestAllocator::deallocate
instead of delete[]
.
Knowledge: Default operations, exception safety.
Estimated duration: 5-60 minutes.
Exercise 2 - Combined IO streams
Goal: Write a function that appends (<<
) each of its arguments to a std::stringstream
.
- Implement the function in
make_sstream.hpp
. - Make the 6 unit tests pass.
Knowledge: Variadic templates, perfect forwarding.
Estimated duration: 5-20 minutes.
Thanks for attending
Questions?
Contact
Feel free to contact me if you have questions later on.
143042@nhtv.nl | |
Slack | Caspar143042 |
Discord | Aidiakapi#2177 |
Please give me feedback! https://goo.gl/forms/zVXOH97o1iyB4qIj1