Tentang Move Semantics di C++
Diterbitkan pada 5 June 2022 19:20 +0700
Disclaimer
Post ini bukan merupakan tutorial atau menerangkan best practices dalam menggunakan move semantics di C++. Saya sendiri bukan seorang ahli dalam topik ini, namun karena saya gampang lupa jika tidak mencatat, maka tulisan ini dibuat sebagai catatan saya pribadi yang akan terus diperbarui seiring bertambahnya pengetahuan tentang move semantics.
Jika Anda tertarik mempelajari apa itu move semantics, mengapa kita perlu memahami move semantics, dan bagaimana cara menggunakan nya, anda dapat melewati teks di bawah ini dan mengklik tautan-tauran referensi yang saya cantumkan di akhir.
Case 1: Membuat vector dari objek dari kelas yang dibuat sendiri
Membuat vector dari objek dari kelas Device untuk menampung data perangkat-perangkat bluetooth low energy.
Berikut adalah definisi kelas Device:
#include <string>
class Device {
public:
Device(std::string name): name_(name) {}
std::string getName()
{
return name_;
}
private:
std::string name_;
};
Kemudian, buat vector untuk menampung objek dari kelas Device.
#include <vector>
// buat type alias untuk vector dari Device
using Devices = std::vector<Device>;
int main()
{
Devices devs;
Device one("One");
Device two("Two");
devs.push_back(one);
devs.push_back(two);
return 0;
}
Jika saya coba untuk mencetak nama dari object one dan two dengan menggunakan std::cout seperti pada kutipan kode berikut:
// baris pertama file c++
#include <iostream>
// ...
int main()
{
Devices devs;
Device one("One");
Device two("Two");
devs.push_back(one);
devs.push_back(two);
std::cout << "Doing something with: " << one.getName() << std::endl;
std::cout << "Doing something with: " << two.getName() << std::endl;
return 0;
}
Keluaran dari program ini adalah:
Doing something with
Doing something with
Apa yang terjadi? Seharusnya program di atas mencetak Doing something with One dan Doing something with Two.
Update 1:
Kesalahan penulisan kode yang mengakibatkan hasil yang salah.
Sebelumnya, kita menganggap bahwa dengan memanggil push_back pada vector akan memindahkan member dari objek yang diberikan pada vector.
Namun, yang terjadi adalah push_back membuat objek baru dan menyalin member objek yang menjadi argumen dari fungsi push_back ke objek baru tersebut.
Sehingga, seharusnya keluaran dari program yang dihasilkan adalah sebagai berikut:
Device(One) created, address: 0x7fffb2de5ee0
Device(Two) created, address: 0x7fffb2de5ec0
Doing something with One
Doing something with Two
About to push_back devices...
Doing something with One
Doing something with Two
Raw Pointer dalam Sebuah Class
Dalam kasus advertisement data oleh perangkat bluetooth low energy, setiap perangkat yang terpindai oleh perangkat lain akan mencantumkan advertisement data. Advertisement data merupakan kumpulan bytes yang ukuran totalnya tidak melebihi 31 byte.
Biasanya advertisement data direpresentasikan oleh array dengan tipe data uint8_t atau unsigned char.
Jika kita ingin mencantumkan data tersebut ke dalam kelas Device, maka kita perlu menambahkan member baru.
#include <string>
class Device {
public:
Device(std::string name, uint8_t *adv_data, size_t adv_data_len): name_(name)
{
// alokasikan memory untuk advertisement data
adv_data_ = new uint8_t[adv_data_len];
// copy advertisement data
memcpy(adv_data_, adv_data, adv_data_len);
adv_data_len_ = adv_data_len;
}
std::string getName()
{
return name_;
}
uint8_t* getAdvertisementData()
{
return adv_data_;
}
private:
std::string name_;
uint8_t *adv_data_;
size_t adv_data_len_;
};
Karena kita menggunakan new untuk mengalokasikan tempat di memory, maka kita harus membebaskan memory tersebut
ketika kelas Device tidak lagi dibutuhkan. Artinya, kita memerlukan destructor untuk kelas Device.
#include <string>
class Device {
public:
Device(std::string name, uint8_t *adv_data, size_t adv_data_len): name_(name)
{
// alokasikan memory untuk advertisement data
adv_data_ = new uint8_t[adv_data_len];
// copy advertisement data
memcpy(adv_data_, adv_data, adv_data_len);
adv_data_len_ = adv_data_len;
}
~Device()
{
delete [] adv_data_;
}
std::string getName()
{
return name_;
}
uint8_t* getAdvertisementData()
{
return adv_data_;
}
private:
std::string name_;
uint8_t *adv_data_;
size_t adv_data_len_;
};
Di titik ini, kelas Device dapat menampung advertisement data yang akan diproses oleh program secara keseluruhan.
Namun, hal ini merupakan awal dari munculnya bug menyebalkan.
// baris deklarasi kelas dan type alias
// ...
int main()
{
// advertisement data flags sebanyak 2 bytes
// format: length type data
// total size: 3 bytes
uint8_t adv_data[3] = {0x02, 0x01, 0x06};
Devices devs;
Device one("One", adv_data, 3);
Device two("Two", adv_data, 3);
devs.push_back(one);
devs.push_back(two);
std::cout << "Doing something with: " << one.getName() << std::endl;
std::cout << "Doing something with: " << two.getName() << std::endl;
return 0;
}
Kita hanya perlu menambahkan advertisement data dan length dari advertisement data agar program dapat dikompilasi. Namun, apa yang terjadi ketika program dijalankan?
Berikut adalah output dari program tersebut:
ASM generation compiler returned: 0
Execution build compiler returned: 0
Program returned: 139
free(): double free detected in tcache 2
Doing something with: One
Doing something with: Two
Program yang kita buat menghasilkan return value 139, bukan 0. Dan yang lebih penting adalah pesan berikut:
free(): double free detected in tcache 2
Double free terdeteksi ketika program kita berusaha membebaskan memory dengan alamaty ang sama menggunakan delete atau delete [] lebih dari satu kali.
Seorang developer di sebuah untaian diskusi di StackOverflow mengatakan bahwa double free merupakan undefined behavior yang pada umumnya harus dihindari dan dicegah!
Namun, bagaimana bisa double free tersebut terjadi? Ketika kita menambahkan objek dari kelas Device ke sebuah vector, mestinya objek tersebut disalin.
Tapi, apakah benar demikian?
Special Member Function
Setiap kelas yang memiliki member akan diberikan special member function oleh compiler. Sebagai contoh:
struct SimpleDevice {
std::string name;
};
Kelas SimpleDevice pada kutipan kode di atas akan diberikan special member function oleh compiler, berikut adalah member function yang diberikan:
- Default constructor.
- Default destructor.
- Default copy constructor.
- Default copy assignment operator.
- Default move constructor.
- Default move assignment operator.
Special member function tersebut akan dihasilkan oleh compiler jika special member function tersebut tidak dideklarasikan secara eksplisit oleh programmer. Klaus Iglberger menjelaskan setiap special member function tersebut dengan sangat baik pada sesi CppCon 2021 di jalur back to basics.
Jika kita mendefinisikan destructor secara eksplisit seperti yang telah kita lakukan pada kelas Device, maka kita harus mendefinisikan
special member function lain secara eksplisit.
Shallow Copy
Jika kita mencantumkan raw pointer yang dikelola secara mandiri oleh sebuah objek (seperti yang dilakukan oleh objek dari kelas Device), default copy constructor dan copy assignment operator
hanya akan menyalin pointer tersebut, sedangkan memory yang dialokasikan tidak disalin.
Ketika objek Device disalin, salinan dari objek tersebut akan menunjuk ke alamat memory adv_data_ yang sama dengan objek asli.
Ini yang menyebabkan terjadinya double free.
Solusi: Implementasi Copy Constructor dan Copy Assignment Operator
Agar adv_data_ pada kelas Device tersalin dengan benar, maka kita harus mengimplementasikan copy constructor dan copy assignment operator.
// deklarasi kelas Device
// ...
Device(const Device& other) noexcept
{
if (&other == this) {
return;
}
name_ = other.name_;
adv_data_ = new uint8_t[other.adv_data_len_];
memcpy(adv_data_, other.adv_data_, other.adv_data_len_);
adv_data_len_ = other.adv_data_len_;
}
Device& operator=(const Device& other) noexcept
{
if (&other == this)
{
return *this;
}
name_ = other.name_;
adv_data_ = new uint8_t[other.adv_data_len_];
memcpy(adv_data_, other.adv_data_, other.adv_data_len_);
adv_data_len_ = other.adv_data_len_;
return *this;
}
Copy constructor dan copy assignment operator tersebut menyelesaikan masalah double free yang terjadi karena raw pointer. Masalah tersebut terselesaikan karena kita tidak lagi hanya menyalin pointer, namun kita juga menyalin memory yang ditunjuk oleh pointer tersebut.
Copy constructor memiliki signature seperti berikut
Device(const Device& other)
{
// ...
}
Sedangkan copy assignment operator memiliki signature seperti berikut
Device& operator=(const Device& other)
{
// ...
}
Program yang telah diperbarui dengan menambahkan copy constructor dan copy assignment operator untuk kelas Device akan kembali berjalan normal.
Lihat hasil program tersebut di sini.
ASM generation compiler returned: 0
Execution build compiler returned: 0
Program returned: 0
Doing something with: One
Doing something with: Two
Rvalue dan Lvalue
Sebelum memasuki topik utama, penting untuk mengetahui tentang rvalue dan lvalue beserta rvalue reference dan lvalue reference.
Apa itu Lvalue?
Lvalue merupakan sebuah expression atau ekspresi yang evaluasi nya menentukan identitas dari sebuah objek atau fungsi. Singkatnya, nilai yang memiliki nama atau alamat yang merupakan sebuah lvalue.
Sebagai contoh, berikut adalah kumpulan dari lvalue
int c = 0;
// c adalah lvalue
int getRandomNumber()
{
return 0;
}
// getRandomNumber adalah lvalue
// karena c merupakan lvalue, maka alamat c dapat diidentifikasi
std::cout << reinterpret_cast<const void*>(c) << std::endl;
// begitu pula dengan getRandomNumber
std::cout << reinterpret_cast<const void*>(getRandomNumber) << std::endl;
Apa itu Rvalue?
Rvalue merupakan kebalikan dari lvalue. Ekspresi yang menghasilkan objek sementara atau temporary object merupakan ekspresi rvalue.
int c = 10;
// c merupakan lvalue, 10 merupakan ekspresi rvalue.
// 10 merupakan objek sementara, dalam kasus ini dapat digunakan
// kembali oleh variabel c
int getRandomNumber()
{
return 0;
}
// ekspresi berikut merupakan ekspresi rvalue
// getRandomNumber mengembalikan 0 yang tidak digunakan lagi
// oleh siapapun
getRandomNumber();
// setelah baris di atas, nila yang dihasilkan oleh fungsi
// tersebut akan lenyap
Ciri khas dari sebuah rvalue expression adalah kita tidak dapat menentukan alamat
dari objek yang dihasilkan dengan menggunakan operator reference &.
std::cout << reinterpret_cast<const void*>(&10) << std::endl;
// baris di atas tidak dapat dicompile dan akan berakhir sebagai error
error: lvalue required as unary '&' operand
std::cout << reinterpret_cast<const void*>(&10) << std::endl;
Referensi Lvalue dan Referensi Rvalue
Seperti namanya, referensi lvalue atau lvalue reference merupakan referensi ke sebuah lvalue.
int c = 10;
// c merupakan lvalue
int &d = c;
// d merupakan referensi lvalue
Referensi lvalue ditandai dengan penggunaan tanda &. Referensi lvalue juga dapat memiliki atribut const
untuk menandai bahwa referensi tersebut merupakan immutable reference.
int c = 10;
const int &d = c;
// d = 12;
// baris di atas akan menghasilkan galat ketika kode dikompilasi
Immutable lvalue reference dapat digunakan untuk mengacu ke objek sementara, cara ini digunakan untuk memperpanjang umur objek sementara yang harusnya menguap setelah ekspresi yang menghasilkan objek tersebut selesai dievaluasi menjadi tetap hidup sampai akhir dari scope referensi tersebut.
std::string getName()
{
return "Winter";
}
int main()
{
getName(); // merupakan rvalue atau objek sementara
// String "Winter" akan lenyap setelah ekspresi tersebut
// selesai dieksekusi
// std::string &name = getName();
// akan menghasilkan error
const std::string &name = getName();
// objek sementara hasil evaluasi getName() akan tetap
// hidup sampai akhir dari scope yang menaungi
// referensi lvalue
return 0;
}
Berikut adalah galat yang dihasilkan jika baris std::string &name = getName() dikompilasi:
In function 'int main()':
error: cannot bind non-const lvalue reference of type 'std::string&' {aka 'std::__cxx11::basic_string<char>&'} to an rvalue of type 'std::string' {aka 'std::__cxx11::basic_string<char>'}
| std::string& name = getName();
C++ menjamin bahwa objek sementara getName(), yang hidupnya tidak akan lama dan akan segera lenyap dari kenyataan, tidak dapat dimodifikasi.
Referensi lvalue dengan const merupakan satu-satunya cara untuk memberikan referensi kepada objek sementara, sampai akhirnya standar
C++11 dirilis.
Referensi Rvalue
Referensi rvalue merupakan cara baru yang diperkenalkan oleh C++11 untuk mendeteksi objek sementara.
Referensi rvalue menggunakan sintaks dengan tanda &&. Dengan menggunakan referensi rvalue, kita dapat mengikat
objek sementara (rvalue) dengan rvalue reference yang bersifat mutable.
void printReference(const std::string& str)
{
std::cout << "lvalue reference: " << str << std::endl;
}
void printReference(std::string&& str)
{
std::cout << "rvalue reference: " << str << std::endl;
}
int main()
{
std::string name = "Winter";
printReference(name);
printReference("Alwin");
return 0;
}
Kode di atas akan menghasilkan keluaran seperti berikut:
lvalue reference: Winter
rvalue reference: Alwin
Hal tersebut membuktikan bahwa rvalue reference dapat digunakan untuk mendeteksi objek sementara.
Dalam kasus ini string "Alwin" merupakan objek sementara, sehingga compiler menggunakan
fungsi printReference(std::string&& str).
Referensi rvalue juga dapat bersifat immutable dengan menggunakan const. Namun, immutable rvalue reference
akan jarang sekali ditemukan dalam kasus move semantics.
Move Semantics
Kembali ke kasus dengan kelas Device, bagaimana jika kita ingin memindahkan objek dari kelas Device tapi
tanpa menyalin isi dari raw pointer adv_data_?
Mengapa kita ingin melakukan hal tersebut?
int main()
{
uint8_t adv_data[3] = {0x02, 0x01, 0x06};
Device dev("one", adv_data, 3);
Device another(dev);
return 0;
}
Misalkan, kita mempunyai objek dari kelas Device dengan nama dev. Kemudian, dikarenakan
oleh alasan tertentu, kita perlu memindahkan device ke tempat lain. Mudahnya,
kita bisa saja menyalin objek dev ke objek another seperti yang ditunjukkan oleh
kode di atas.
Seperti yang dijelaskan di bagian sebelumnya, semua member dari objek dev disalin ke objek
another, tidak terkecuali raw pointer adv_data_. Jika kita masih ingin menggunakan objek
dev, maka menyalin objek dev ke objek another merupakan cara yang masuk akal dan dapat diterima.
Namun, jika kita tidak ingin menggunakan objek dev lagi setelahnya, cara tersebut tidak efisien.
Pertama, kita mengalokasikan memory untuk member adv_data_ objek dev. Kemudian, kita alokasikan
memory untuk member yang sama bagi objek another. Kemudian, data yang ditunjuk oleh member adv_data_
objek dev disalin ke member yang sama di objek another. Kita baru saja melakukan operasi yang sama
berulang kali untuk data yang sama.
Lalu, bagaimana caranya agar kita dapat membuat another dari objek dev tanpa melakukan kegiatan yang tidak efisien?
Move Constructor dan Move Assignment Operator
Move constructor dan move assignment operator mirip dengan copy constructor dan copy assignment operator, hanya maksud yang dinyatakan berbeda. Sintaks dari kedua pasangan tersebut sangat mirip dengan satu sama lainnya.
// Move Constructor
Device(Device&& other)
{
// ...
}
// Move Assignment Operator
Device& operator=(Device&& other)
{
// ...
}
Move constructor dan move assignment operator menggunakan mutable rvalue reference, berbeda dengan copy constructor dan copy assignment operator yang menggunakan immutable lvalue reference.
Dengan menggunakan dua special member function tersebut, objek another dapat mencuri member adv_data_ milik objek dev,
sehingga tidak ada proses penyalinan yang tidak efisien!
Berikut adalah implementasi dua special member function tersebut:
Device(Device&& other): name_(other.name_)
{
if (&other == this) {
return;
}
adv_data_ = other.adv_data_;
other.adv_data_ = nullptr;
adv_data_len_ = other.adv_data_len_;
}
Device& operator=(Device&& other)
{
if (&other == this)
{
return *this;
}
name_ = other.name_;
adv_data_ = other.adv_data_;
other.adv_data_ = nullptr;
adv_data_len_ = other.adv_data_len_;
return *this;
}
Perhatikan dua baris berikut:
adv_data_ = other.adv_data_;
other.adv_data_ = nullptr;
Dua baris tersebut merupakan cara untuk mencuri member yang merupakan raw pointer.
Lalu, mengapa baris ke-2 dari kode tersebut penting? Karena jika member adv_data_ dari
objek other di-dealokasikan, maka program akan berhenti secara tidak normal yang
disebabkan oleh double free!
Oleh karena itu, move constructor dan move assignment operator menggunakan referensi rvalue yang bersifat
mutable. Karena jika kita menggunakan immutable rvalue reference, kita tidak dapat mengubah
member adv_data_ milik objek other menjadi nullptr dan double free tidak dapat dihindari!
Selain itu, implementasi destructor kelas Device juga perlu diubah menjadi seperti berikut:
~Device()
{
if (adv_data_ != nullptr)
{
delete [] adv_data_;
adv_data_ = nullptr;
}
}
Apakah dengan menambahkan move constructor dan move assignment operator akan menyelesaikan masalah inefisiensi dalam kasus ini?
Tentu saja tidak!
Member name_ adalah objek std::string yang memiliki move constructor dan move assignment operator.
Namun, name_ yang dimiliki oleh objek other di move constructor dan move assignment operator tidak dipindahkan, melainkan disalin!
Meskipun member adv_data_ dapat dipindahkan, member name_ tetap disalin. Sehingga, masalah inefisiensi kita tidak sepenuhnya terselesaikan.
Mengapa demikian?
Rvalue Reference adalah Lvalue
Betul sekali, referensi rvalue atau rvalue reference adalah lvalue, BUKAN rvalue.
Rvalue reference other di move constructor dan move assignment operator merupakan lvalue. Kemudian,
member name_ juga merupakan lvalue, sehingga ekspresi name_(other.name_) memanggil copy constructor, bukan move constructor!
Lalu, bagaimana caranya agar kita dapat memindahkan member name_ dari objek dev ke objek another dengan menggunakan move constructor
jika kenyataan nya kita memanggil copy constructor di dalam move constructor dan move assignment operator?
std::move
std::move merupakan sebuah fungsi yang dapat digunakan dengan mencantumkan header <utility>.
Walaupun bernama move, namun kenyataan nya fungsi tersebut TIDAK memindahkan apapun.
Lalu, mengapa kita memerlukan fungsi std::move?
Ingat bahwa move constructor dan move assignment operator memerlukan rvalue reference sebagai argument?
std::move mengubah lvalue menjadi rvalue sehingga kita dapat memanggil move constructor atau move assingment operator!
std::move juga dapat diartikan sebagai cara untuk menyatakan
Saya mempunyai sebuah lvalue, tapi saya tidak akan membutuhkannya lagi.
Device(Device&& other): name_(std::move(other.name_))
{
if (&other == this) {
return;
}
adv_data_ = other.adv_data_;
other.adv_data_ = nullptr;
adv_data_len_ = other.adv_data_len_;
}
Device& operator=(Device&& other)
{
if (&other == this)
{
return *this;
}
name_ = std::move(other.name_);
adv_data_ = other.adv_data_;
other.adv_data_ = nullptr;
adv_data_len_ = other.adv_data_len_;
return *this;
}
Dengan menggunakan std::move, kita menjamin bahwa member name_ dari objek other di move constructor dan move assignment operator
di kelas Device tidak akan digunakan kembali sehingga member dari std::string name_ dapat dipindahkan.
Apakah sekarang move constructor dan move assignment operator dapat memindahkan member dari objek kelas Device ke objek baru dari kelas tersebut? Ya!
Sekarang, perhatikan kembali kode fungsi main
int main()
{
uint8_t adv_data[3] = {0x02, 0x01, 0x06};
Device dev("one", adv_data, 3);
Device another(dev);
return 0;
}
Meskipun kita telah mendefinisikan move constructor dan move assignment operator dengan baik dan benar, dua special member function tersebut tidak digunakan sama sekali di fungsi main!
Mengapa demikian?
Objek dev merupakan lvalue, dan kita menggunakan lvalue tersebut sebagai argument dari constructor
kelas Device untuk membangun objek another. Apa yang dilakukan jika kita memberikan referensi lvalue
ke sebuah constructor kelas? Ya, kita menggunakan copy constructor!
Lagi-lagi, std::move sangat berguna dalam kasus ini.
int main()
{
uint8_t adv_data[3] = {0x02, 0x01, 0x06};
Device dev("one", adv_data, 3);
Device another(std::move(dev));
return 0;
}
Dengan demikian, kita telah menggunakan move semantics untuk memindahkan semua member objek dev ke objek another.
Untuk membuktikan hal tersebut, kita dapat mencetak nama dari objek dev dengan menggunakan std::cout.
std::cout << "dev name_: " << dev.getName() << std::endl;
std::cout << "another name_: " << another.getName() << std::endl;
Kode tersebut akan menghasilkan keluaran seperti berikut:
dev name_:
another name_: one
Kesimpulan
Move semantics dapat membantu proses pemindahan member-member dari sebuah objek tanpa melakukan proses penyalinan yang tidak efisien.
Kelas yang memiliki raw pointer sebagai member harus mendefinisikan special member function seperti destructor, copy constructor, copy assignment operator, move constructor, dan move assignment operator secara eksplisit untuk memastikan bahwa raw pointer tersebut diperlakukan dengan penuh kehati-hatian untuk menghindari undefined behavior dan crash yang dapat membuat kepala pusing.
Sumber kode: https://godbolt.org/z/e8dE1brKc