Tentang Move Semantics di C++

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

Referensi