Chapter sebelumnya menata data agar hemat cache line; chapter ini menyerang biaya yang sering muncul di loop yang sama: penyalinan objek dan realokasi buffer. Setiap kali std::vector tumbuh melewati kapasitasnya, seluruh isi dipindahkan; setiap kali kita menyalin std::string padahal cukup memindahkannya, satu alokasi heap plus satu salinan byte terjadi percuma. Move semantics yang sudah kita pelajari secara teori kini kita pakai sebagai alat pengukur: kita memasang penghitung alokasi pada operator new, lalu membandingkan variasi kode yang hasilnya persis sama. Angka yang kita kejar bukan milidetik, melainkan jumlah alokasi dan jumlah constructor — keduanya deterministik, jadi layak dijadikan output terverifikasi.

reserve Sebelum Loop push_back #

Kebiasaan paling murah dengan dampak terbesar: bila jumlah elemen sudah diketahui (atau bisa ditaksir), panggil reserve satu kali sebelum loop push_back. Program berikut memasang penghitung global pada operator new sehingga setiap alokasi heap tercatat, lalu mengisi satu juta elemen dengan dua cara:

cpp
// Menghitung alokasi heap yang benar-benar terjadi saat mengisi
// std::vector dengan satu juta elemen, dengan dan tanpa reserve.
// Semua angka pada output adalah hasil penghitungan nyata (jumlah
// alokasi, kapasitas), bukan pengukuran waktu, sehingga deterministik.
#include <cstdlib>
#include <iostream>
#include <new>
#include <vector>

long alokasiBaru = 0;        // penghitung global pemanggilan operator new

void* operator new(std::size_t n) {
    ++alokasiBaru;
    if (void* p = std::malloc(n)) return p;
    throw std::bad_alloc{};
}

void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }

const int N = 1000000;

int main() {
    std::cout << "mengisi " << N << " elemen int\n";   // pemanasan iostream

    // Versi tanpa reserve: vector tumbuh dan merealokasi berkali-kali.
    alokasiBaru = 0;
    std::vector<int> v;
    for (int i = 0; i < N; ++i) v.push_back(i);
    long a1 = alokasiBaru;
    std::size_t kap1 = v.capacity();
    v.clear();
    v.shrink_to_fit();

    // Versi dengan reserve: satu alokasi dari awal, tidak pernah realokasi.
    alokasiBaru = 0;
    std::vector<int> w;
    w.reserve(N);
    for (int i = 0; i < N; ++i) w.push_back(i);
    long a2 = alokasiBaru;
    std::size_t kap2 = w.capacity();

    std::cout << "tanpa reserve : alokasi = " << a1
              << ", kapasitas akhir = " << kap1 << '\n';
    std::cout << "dengan reserve: alokasi = " << a2
              << ", kapasitas akhir = " << kap2 << '\n';
    std::cout << "kapasitas = " << kap1 << " berarti " << kap1 - N
              << " slot int menumpuk tanpa terpakai\n";
    return 0;
}

Output yang Dihasilkan:

text
mengisi 1000000 elemen int
tanpa reserve : alokasi = 21, kapasitas akhir = 1048576
dengan reserve: alokasi = 1, kapasitas akhir = 1000000
kapasitas = 1048576 berarti 48576 slot int menumpuk tanpa terpakai

Bedah baris penting:

  1. operator new yang diganti: definisi global ini menaikkan alokasiBaru setiap ada permintaan heap, lalu mendelegasikan ke malloc. Versi operator delete bersifat noexcept dan mendelegasikan ke free. Seluruh program — termasuk container — melewati penghitung ini.
  2. Pemanasan dan reset: baris std::cout pertama memastikan fasilitas output sudah mengalokasikan kebutuhannya; setelah itu penghitung direset ke nol sehingga yang terukur hanya operasi yang kita selidiki.
  3. 21 alokasi tanpa reserve: std::vector kosong menggandakan kapasitas setiap kali penuh — 1, 2, 4, … sampai 1.048.576 untuk menampung satu juta elemen, dengan total 21 alokasi dan penggandaan isi berulang kali.
  4. 1 alokasi dengan reserve: w.reserve(N) menyewa kapasitas tepat 1.000.000 slot sejak awal, sehingga 20 realokasi dan seluruh pemindahan isi menghilang. Slot sisa 48.576 pada versi tanpa reserve juga menunjukkan memori yang menumpuk tanpa terpakai.

emplace_back versus push_back #

Sekarang kita hitung biaya constructor. Class Catatan membawa penghitung statis untuk constructor, copy constructor, dan move constructor, sehingga setiap pemanggilan tercatat:

cpp
// Membedah biaya menambah elemen ke std::vector: push_back vs emplace_back,
// dan peran noexcept pada move constructor saat vector merealokasi.
// Semua angka adalah hasil penghitung constructor yang deterministik.
#include <iostream>
#include <string>
#include <vector>

struct Catatan {
    int id;
    std::string nama;
    static int nCtor, nCopy, nMove;

    Catatan(int i, std::string n) : id(i), nama(std::move(n)) { ++nCtor; }
    Catatan(const Catatan& o) : id(o.id), nama(o.nama) { ++nCopy; }
    Catatan(Catatan&& o) noexcept            // noexcept: aman untuk realokasi
        : id(o.id), nama(std::move(o.nama)) { ++nMove; }
};
int Catatan::nCtor = 0;
int Catatan::nCopy = 0;
int Catatan::nMove = 0;

struct CatatanHatiHati {                     // sama, tetapi move TIDAK noexcept
    int id;
    std::string nama;
    static int nCtor, nCopy, nMove;

    CatatanHatiHati(int i, std::string n) : id(i), nama(std::move(n)) { ++nCtor; }
    CatatanHatiHati(const CatatanHatiHati& o) : id(o.id), nama(o.nama) { ++nCopy; }
    CatatanHatiHati(CatatanHatiHati&& o)
        : id(o.id), nama(std::move(o.nama)) { ++nMove; }
};
int CatatanHatiHati::nCtor = 0;
int CatatanHatiHati::nCopy = 0;
int CatatanHatiHati::nMove = 0;

int main() {
    // A. push_back vs emplace_back (reserve(2) agar tidak ada realokasi)
    std::vector<Catatan> a;
    a.reserve(2);
    a.push_back(Catatan{1, "budi"});
    a.push_back(Catatan{2, "siti"});
    std::cout << "push_back   : ctor = " << Catatan::nCtor
              << ", copy = " << Catatan::nCopy
              << ", move = " << Catatan::nMove << '\n';

    Catatan::nCtor = 0; Catatan::nCopy = 0; Catatan::nMove = 0;
    std::vector<Catatan> b;
    b.reserve(2);
    b.emplace_back(1, "budi");
    b.emplace_back(2, "siti");
    std::cout << "emplace_back: ctor = " << Catatan::nCtor
              << ", copy = " << Catatan::nCopy
              << ", move = " << Catatan::nMove << '\n';

    // B. realokasi tanpa reserve: noexcept menentukan move atau copy
    Catatan::nCtor = 0; Catatan::nCopy = 0; Catatan::nMove = 0;
    std::vector<Catatan> cepat;
    for (int i = 0; i < 4; ++i) cepat.push_back(Catatan{i, "isi"});
    std::cout << "realokasi noexcept : ctor = " << Catatan::nCtor
              << ", copy = " << Catatan::nCopy
              << ", move = " << Catatan::nMove << '\n';

    std::vector<CatatanHatiHati> hatiHati;
    for (int i = 0; i < 4; ++i) hatiHati.push_back(CatatanHatiHati{i, "isi"});
    std::cout << "realokasi tanpa noexcept: ctor = " << CatatanHatiHati::nCtor
              << ", copy = " << CatatanHatiHati::nCopy
              << ", move = " << CatatanHatiHati::nMove << '\n';
    return 0;
}

Output yang Dihasilkan:

text
push_back   : ctor = 2, copy = 0, move = 2
emplace_back: ctor = 2, copy = 0, move = 0
realokasi noexcept : ctor = 4, copy = 0, move = 7
realokasi tanpa noexcept: ctor = 4, copy = 3, move = 4

Yang perlu dibaca dari angka-angka ini:

  1. push_back(Catatan{1, "budi"}): object sementara dibuat (ctor = 1), lalu dipindahkan ke slot vector (move = 1). Per elemen ada satu pindahan yang sebenarnya bisa dihindari.
  2. emplace_back(1, "budi"): argumen diteruskan langsung ke constructor di dalam slot vector — ctor = 1, move = 0. Ketika constructor-nya mahal, emplace_back menghemat satu lapis pindahan.
  3. Realokasi dengan move noexcept: empat push_back menghasilkan 7 pindahan — 4 pindahan sementara ke slot, plus 1 + 2 pindahan isi lama saat kapasitas naik 1 → 2 → 4. Tidak ada satu pun salinan.
  4. Realokasi tanpa noexcept: isi lama kini disalin (copy = 3), bukan dipindah. Alasannya jaminan exception: bila move bisa melempar pengecualian di tengah realokasi, data lama sudah rusak; copy membuat realokasi bisa dibatalkan dengan aman.

Idiom Pass by Value + move #

Untuk setter yang menyimpan salinan (misalnya field std::string), ada idiom sederhana yang aman untuk semua kasus: terima parameter by value, lalu std::move ke tujuan. Kita buktikan biayanya dengan penghitung alokasi, satu kasus per blok dengan nama_ yang selalu baru:

cpp
// Idiom pass by value + std::move untuk setter, dibandingkan dengan
// parameter const std::string&. Penghitung alokasi membuktikan biaya
// setiap kombinasi: lvalue vs rvalue, const& vs by value. String argumen
// dibuat SEBELUM penghitung direset agar yang terukur hanya biaya setter.
#include <cstdlib>
#include <iostream>
#include <new>
#include <string>
#include <utility>

long alokasiBaru = 0;        // penghitung global pemanggilan operator new

void* operator new(std::size_t n) {
    ++alokasiBaru;
    if (void* p = std::malloc(n)) return p;
    throw std::bad_alloc{};
}

void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }

class Pengatur {
public:
    void simpan(const std::string& n) { nama_ = n; }           // selalu salin
    void simpanIdiom(std::string n) { nama_ = std::move(n); }  // idiom by value
private:
    std::string nama_;
};

std::string buatNama() { return std::string(60, 'x'); }  // > SSO, agar alokasi terlihat

int main() {
    std::cout << "idiom pass by value + move\n";

    // Kasus 1: const& + lvalue — nama_ masih kosong, salinan penuh.
    {
        std::string nama = buatNama();
        Pengatur p;
        alokasiBaru = 0;
        p.simpan(nama);
        std::cout << "const& + lvalue : alokasi = " << alokasiBaru << '\n';
    }

    // Kasus 2: by value + lvalue — parameter disalin sekali, lalu dipindah.
    {
        std::string nama = buatNama();
        Pengatur p;
        alokasiBaru = 0;
        p.simpanIdiom(nama);
        std::cout << "by value + lvalue: alokasi = " << alokasiBaru << '\n';
    }

    // Kasus 3: by value + rvalue — parameter dipindah, nama_ ikut dipindah.
    {
        std::string sementara = buatNama();
        Pengatur p;
        alokasiBaru = 0;
        p.simpanIdiom(std::move(sementara));
        std::cout << "by value + rvalue: alokasi = " << alokasiBaru << '\n';
    }

    // Kasus 4: const& + rvalue — const& tetap wajib menyalin isi rvalue.
    {
        std::string sementara = buatNama();
        Pengatur p;
        alokasiBaru = 0;
        p.simpan(std::move(sementara));
        std::cout << "const& + rvalue : alokasi = " << alokasiBaru << '\n';
    }
    return 0;
}

Output yang Dihasilkan:

text
idiom pass by value + move
const& + lvalue : alokasi = 1
by value + lvalue: alokasi = 1
by value + rvalue: alokasi = 0
const& + rvalue : alokasi = 1
Pemanggilanconst&by value + move
lvalue (variabel ada)1 salinan1 salinan (ke parameter)
rvalue (sementara/move)1 salinan0 — semua dipindah

Dua baris pertama menunjukkan idiom tidak pernah lebih buruk: untuk lvalue, keduanya sama-sama satu alokasi, hanya lokasi salinannya yang bergeser (ke parameter, lalu pindah ke field). Baris ketiga adalah kemenangannya: sumber berupa rvalue sehingga parameter dipindah dan field ikut memindah — nol alokasi. Baris keempat menunjukkan const& kehilangan kesempatan yang sama: meskipun argumennya rvalue, parameter const& tidak boleh mengubah sumber, jadi isinya tetap disalin.

Membangun String Secara Bertahap #

Pola terakhir sering muncul di pembangkit laporan dan log: menyusun satu string panjang dari banyak potongan. Ada dua gaya yang hasil akhirnya identik, tetapi biaya alokasinya tidak:

cpp
// Membangun satu string panjang dari banyak potongan: hasil += potongan
// (buffer mengembang seiring waktu) versus hasil = hasil + potongan
// (menyalin seluruh isi dari awal setiap iterasi). Angka alokasi nyata.
#include <cstdlib>
#include <iostream>
#include <new>
#include <string>

long alokasiBaru = 0;        // penghitung global pemanggilan operator new

void* operator new(std::size_t n) {
    ++alokasiBaru;
    if (void* p = std::malloc(n)) return p;
    throw std::bad_alloc{};
}

void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }

// Satu potongan 20+ karakter: melebihi SSO, jadi setiap pemanggilan
// pasti terlihat alokasinya di penghitung.
std::string potongan(int i) {
    std::string s = "catatan-baris-nomor-";
    s += std::to_string(i);
    s += ";";
    return s;                // return by value: pindahan murah, bukan salinan
}

int main() {
    const int N = 200;
    std::cout << "membangun string dari " << N << " potongan\n";

    std::string caraTambah;
    alokasiBaru = 0;
    for (int i = 0; i < N; ++i) caraTambah += potongan(i);
    long a1 = alokasiBaru;
    std::cout << "hasil += potongan : panjang = " << caraTambah.size()
              << ", alokasi = " << a1 << '\n';

    std::string caraSamaDengan;
    alokasiBaru = 0;
    for (int i = 0; i < N; ++i) caraSamaDengan = caraSamaDengan + potongan(i);
    long a2 = alokasiBaru;
    std::cout << "hasil = hasil + p : panjang = " << caraSamaDengan.size()
              << ", alokasi = " << a2 << '\n';

    std::cout << "isi kedua hasil identik: "
              << (caraTambah == caraSamaDengan ? "ya" : "tidak") << '\n';
    return 0;
}

Output yang Dihasilkan:

text
membangun string dari 200 potongan
hasil += potongan : panjang = 4690, alokasi = 409
hasil = hasil + p : panjang = 4690, alokasi = 599
isi kedua hasil identik: ya

Dari total 4690 karakter, 400 alokasi di kedua loop adalah biaya pembentuk potongan itu sendiri (dua alokasi per potongan, karena tiap potongan melebihi SSO). Sisanya yang membedakan: += hanya mengembangkan buffer ketika kapasitas kurang — sembilan kali penggandaan untuk seluruh 200 iterasi — sedangkan hasil = hasil + potongan wajib membuat string hasil baru setiap iterasi, menyalin ulang seluruh isi yang sudah terkumpul (199 alokasi tambahan). Selisihnya tumbuh kuadratik: perbandingan alokasi makin timpang ketika potongan makin banyak. Di sinilah move bekerja untuk kita: return s; pada potongan memindahkan buffer hasil, bukan menyalinnya, dan caraTambah += potongan(i) menempelkannya tanpa salinan baru.

Move sebagai Kebiasaan, Bukan Trik #

Keempat demo di atas mengajarkan satu kebiasaan: ketahui di mana data berpindah dan pastikan pindahannya berupa move. reserve sebelum loop, emplace_back untuk constructor berparameter, idiom by value + move untuk setter, dan += untuk akumulasi string — semuanya perubahan kecil yang bisa kita buktikan dengan penghitung, bukan selera. Ukuran sisa masalahnya kini bergeser: setelah salinan yang percuma dihapus, alokasi heap yang tersisa masih ada — string baru di dalam loop, buffer yang dibuat-buat ulang, dan objek yang bisa hidup di memori statis. Chapter berikutnya menyerang lapisan itu: mengurangi alokasi memori itu sendiri.

Rujukan resmi: vector::reserve , emplace_back , dan std::move .