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:
// 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:
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 terpakaiBedah baris penting:
operator newyang diganti: definisi global ini menaikkanalokasiBarusetiap ada permintaan heap, lalu mendelegasikan kemalloc. Versioperator deletebersifat noexcept dan mendelegasikan kefree. Seluruh program — termasuk container — melewati penghitung ini.- Pemanasan dan reset: baris
std::coutpertama memastikan fasilitas output sudah mengalokasikan kebutuhannya; setelah itu penghitung direset ke nol sehingga yang terukur hanya operasi yang kita selidiki. - 21 alokasi tanpa reserve:
std::vectorkosong 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. - 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:
// 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:
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 = 4Yang perlu dibaca dari angka-angka ini:
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.emplace_back(1, "budi"): argumen diteruskan langsung ke constructor di dalam slot vector — ctor = 1, move = 0. Ketika constructor-nya mahal,emplace_backmenghemat satu lapis pindahan.- Realokasi dengan move noexcept: empat
push_backmenghasilkan 7 pindahan — 4 pindahan sementara ke slot, plus 1 + 2 pindahan isi lama saat kapasitas naik 1 → 2 → 4. Tidak ada satu pun salinan. - 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:
// 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:
idiom pass by value + move
const& + lvalue : alokasi = 1
by value + lvalue: alokasi = 1
by value + rvalue: alokasi = 0
const& + rvalue : alokasi = 1| Pemanggilan | const& | by value + move |
|---|---|---|
| lvalue (variabel ada) | 1 salinan | 1 salinan (ke parameter) |
| rvalue (sementara/move) | 1 salinan | 0 — 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:
// 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:
membangun string dari 200 potongan
hasil += potongan : panjang = 4690, alokasi = 409
hasil = hasil + p : panjang = 4690, alokasi = 599
isi kedua hasil identik: yaDari 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 .