C++ Classes and Objects: Constructors and Access Control
Key takeaways
A class defines a blueprint; objects are the instances you create from it. This guide walks through C++ class fundamentals — constructors, encapsulation, destructors, and copy semantics — with complete working examples.
Class as Blueprint
A class defines the structure and behavior of a type. An object is a specific instance of that class — one particular entity created from the blueprint.
class = blueprint (defines once what the type is)
object = instance (you can create as many as you need)
The same class can produce thousands of objects, each with its own data, but all sharing the same behavior.
Your First Class
#include <iostream>
#include <string>
class Person {
public:
std::string name;
int age;
void introduce() const {
std::cout << "Hi, I'm " << name
<< " and I'm " << age << " years old.\n";
}
};
int main() {
Person alice; // create object on the stack
alice.name = "Alice";
alice.age = 25;
alice.introduce(); // Hi, I'm Alice and I'm 25 years old.
Person bob;
bob.name = "Bob";
bob.age = 30;
bob.introduce(); // Hi, I'm Bob and I'm 30 years old.
}
alice and bob are separate objects — each has its own name and age, but both share the introduce() method defined in the class.
“Share” is literal: member functions are compiled once, and each call quietly passes the address of the object as a hidden this pointer. That is why sizeof(Person) is just the size of a std::string plus an int (plus padding) no matter how many methods you add — only data members take space in each object, while virtual functions add a single hidden pointer.
One trap hides in this first example. Person alice; leaves age uninitialized, because int has no constructor and the class does not provide one. name is fine (std::string default-constructs to empty), but calling introduce() before setting age prints whatever garbage was on the stack — and in an optimized build the compiler is allowed to assume it never happens. The simplest guard is a default member initializer, int age = 0;, which every constructor then uses unless it says otherwise.
Constructors
A constructor is a special function that runs automatically when an object is created. It has the same name as the class and no return type.
class Rectangle {
public:
double width;
double height;
// Default constructor — no arguments
Rectangle() : width(0), height(0) {}
// Parameterized constructor
Rectangle(double w, double h) : width(w), height(h) {}
double area() const {
return width * height;
}
double perimeter() const {
return 2 * (width + height);
}
};
int main() {
Rectangle r1; // uses default constructor: width=0, height=0
Rectangle r2(5.0, 3.0); // uses parameterized: width=5, height=3
std::cout << r2.area() << '\n'; // 15
std::cout << r2.perimeter() << '\n'; // 16
}
Once you declare any constructor, the compiler stops generating the implicit default constructor. That is why Rectangle writes Rectangle() explicitly; delete it and Rectangle r1; fails with “no matching function for call to ‘Rectangle::Rectangle()’” (GCC) or “no default constructor exists for class” (MSVC). With default member initializers (double width = 0;) you can write Rectangle() = default; instead and keep the zero values in one place.
A classic beginner mistake is writing Rectangle r1(); with parentheses to call the default constructor. That line compiles, but it declares a function named r1 returning a Rectangle (the “most vexing parse”), and the error only appears later when you write r1.area(): “request for member ‘area’ in ‘r1’, which is of non-class type ‘Rectangle()’”. Use Rectangle r1; or Rectangle r1{};.
Consider marking single-argument constructors explicit, as Circle does below. Without it, a constructor like Circle(double) also acts as an implicit conversion, so a function taking const Circle& would silently accept printInfo(2.5).
Member Initializer List
Prefer the initializer list (: member(value)) over assignment in the body. It’s more efficient and required for const members and references:
class Point {
const int x_; // const member — must use initializer list
const int y_;
public:
Point(int x, int y) : x_(x), y_(y) {} // correct
// Wrong — can't assign to const in body:
// Point(int x, int y) { x_ = x; y_ = y; }
};
“More efficient” has a concrete meaning: by the time the constructor body runs, every member has already been constructed. Assigning a std::string member in the body therefore means default-constructing it first and then assigning — two operations instead of one. For int and double there is no measurable difference, but for members without a default constructor (another class that requires arguments), the initializer list is the only option: omitting it produces “no matching function for call to …” pointing at the constructor, which confuses people because the error names a class they did not touch.
Be aware that const data members have a side effect beyond immutability: they make the class non-assignable. Point a(1, 2), b(3, 4); a = b; fails because the implicit copy assignment cannot assign to x_, and so does storing Point in code that needs to reassign it, such as std::sort over a std::vector<Point>. If the goal is “callers can’t change the coordinates”, private non-const members with getters usually serve better.
Access Control: public and private
Encapsulation means hiding the internal state and only exposing a controlled interface. Use private for data members and public for methods:
class BankAccount {
private:
std::string owner_;
double balance_;
public:
BankAccount(const std::string& owner, double initial)
: owner_(owner), balance_(initial) {}
void deposit(double amount) {
if (amount > 0) {
balance_ += amount;
}
}
bool withdraw(double amount) {
if (amount > 0 && amount <= balance_) {
balance_ -= amount;
return true;
}
return false;
}
double getBalance() const { return balance_; }
const std::string& getOwner() const { return owner_; }
};
int main() {
BankAccount account("Alice", 1000.0);
account.deposit(500.0);
account.withdraw(200.0);
std::cout << account.getBalance() << '\n'; // 1300
// account.balance_ = -9999; // compile error — private!
}
Why encapsulation?
- The class controls what state is valid (no negative balance)
- You can change the internal representation later without breaking callers
- Users of the class only need to understand the public interface
Note that private is enforced by the compiler per class, not per object: a BankAccount member function may read another BankAccount’s balance_ directly, which is what makes copy constructors and comparison operators possible. It is also purely a compile-time check — it does not hide the bytes in memory, so it is a tool for keeping code correct, not a security boundary.
Real banking code would not store money in a double. Binary floating point cannot represent most decimal fractions exactly (0.1 + 0.2 is not 0.3), and repeated deposits accumulate rounding errors. Storing an integer number of cents (std::int64_t) is the usual fix; the example keeps double only to stay focused on access control.
const Member Functions
A const member function promises not to modify the object’s state. Mark getters as const:
class Circle {
double radius_;
public:
explicit Circle(double r) : radius_(r) {}
double radius() const { return radius_; } // const — just reads
double area() const { return 3.14159 * radius_ * radius_; } // const
void setRadius(double r) { // non-const — modifies
if (r >= 0) radius_ = r;
}
};
void printInfo(const Circle& c) { // takes const reference
std::cout << "Radius: " << c.radius() << '\n'; // OK — const method
std::cout << "Area: " << c.area() << '\n'; // OK — const method
// c.setRadius(5); // compile error — cannot call non-const on const
}
Forgetting const on a getter is harmless until someone passes the object by const&, which is exactly what well-written functions do. Then every call fails with an error like “passing ‘const Circle’ as ‘this’ argument discards qualifiers” (GCC) or “the object has type qualifiers that are not compatible with the member function” (MSVC). Beginners often “fix” it by removing const from the parameter, which spreads the problem outward; the right fix is to add const to the member function. Adding it from the start is cheap; retrofitting it across a large codebase is painful because const-correctness propagates through every caller.
Destructors
A destructor runs automatically when an object goes out of scope (or is deleted). It’s the place to release resources:
#include <cstdio>
#include <stdexcept>
class FileWriter {
FILE* file_;
public:
explicit FileWriter(const char* path) {
file_ = fopen(path, "w");
if (!file_) throw std::runtime_error("Cannot open file");
}
~FileWriter() { // destructor — called automatically
if (file_) {
fclose(file_); // release the resource
}
}
void write(const char* text) {
fputs(text, file_);
}
};
int main() {
{
FileWriter writer("output.txt");
writer.write("Hello\n");
} // writer goes out of scope — destructor runs, file closed automatically
}
This pattern — acquiring resources in the constructor and releasing them in the destructor — is called RAII (Resource Acquisition Is Initialization). It is the fundamental C++ idiom for preventing resource leaks.
The payoff is that cleanup happens on every exit path — a normal return, an early return, or an exception thrown halfway through write — because stack unwinding runs destructors of all fully constructed local objects. Two details follow from that. First, if the constructor throws (as it does here when fopen fails), the object never finished constructing, so its destructor does not run; that is fine here because there is nothing to clean up yet, but a constructor that acquires two resources must make sure the first is released if the second fails. Second, destructors should not throw: if one throws during unwinding from another exception, the program calls std::terminate. Since C++11 destructors are noexcept by default for exactly this reason.
As written, FileWriter also has the shallow-copy problem described next: FileWriter b = a; copies the FILE*, and both destructors call fclose on the same handle. Adding FileWriter(const FileWriter&) = delete; and FileWriter& operator=(const FileWriter&) = delete; turns that bug into a compile error.
Shallow Copy vs Deep Copy
When a class owns a raw pointer, the default copy behavior (shallow copy) copies the pointer value — two objects end up pointing to the same memory:
class Buffer {
int* data_;
int size_;
public:
Buffer(int size) : size_(size), data_(new int[size]) {}
~Buffer() { delete[] data_; }
// Default copy — copies the pointer, NOT the data
};
int main() {
Buffer b1(10);
Buffer b2 = b1; // both b1.data_ and b2.data_ point to the SAME array
} // b1 and b2 both try to delete[] the same array — double-free crash!
“Crash” is the lucky outcome. Double-freeing is undefined behavior: glibc usually aborts with free(): double free detected in tcache 2, but depending on the allocator and what happened in between, the program may keep running with a corrupted heap and fail somewhere unrelated much later. The first time I chased one of these, the stack trace pointed at an innocent std::string allocation far away from the class that caused it; AddressSanitizer (-fsanitize=address) is the tool that points straight at the second delete[] and the copy that set it up.
(Side note: Buffer declares data_ before size_ but lists size_ first in the initializer list. That works here because both initializers use the constructor parameter, but GCC/Clang emit -Wreorder — see the FAQ below for why the order matters.)
Fix option 1: Rule of Three — define copy constructor and copy assignment:
class Buffer {
int* data_;
int size_;
public:
Buffer(int size) : size_(size), data_(new int[size]) {}
// Copy constructor — deep copy
Buffer(const Buffer& other) : size_(other.size_), data_(new int[other.size_]) {
std::copy(other.data_, other.data_ + size_, data_);
}
// Copy assignment
Buffer& operator=(const Buffer& other) {
if (this != &other) {
delete[] data_;
size_ = other.size_;
data_ = new int[size_];
std::copy(other.data_, other.data_ + size_, data_);
}
return *this;
}
~Buffer() { delete[] data_; }
};
The this != &other check prevents self-assignment (b = b;) from deleting the array it is about to copy from. The operator still has a weakness: if new int[size_] throws std::bad_alloc, data_ has already been deleted and the object is left holding a dangling pointer. The robust version allocates first and deletes second, or uses the copy-and-swap idiom: take the parameter by value (which runs the copy constructor) and std::swap the members. And since C++11, a class like this should also get a move constructor and move assignment (the Rule of Five), otherwise returning a Buffer from a function or storing it in a std::vector falls back to deep copies.
Fix option 2: Rule of Zero — use standard containers (preferred):
class Buffer {
std::vector<int> data_; // vector handles copy/move/destroy automatically
public:
explicit Buffer(int size) : data_(size) {}
// No destructor, copy constructor, or assignment operator needed
};
Prefer Rule of Zero: let std::vector, std::string, and std::unique_ptr handle resource management. Only write Rule of Three/Five when you genuinely need to manage a raw resource.
struct vs class
In C++, struct and class are nearly identical — the only difference is default access:
struct Point { // default: public
int x, y;
Point(int x, int y) : x(x), y(y) {}
};
class PrivatePoint { // default: private
int x, y;
public:
PrivatePoint(int x, int y) : x(x), y(y) {}
int getX() const { return x; }
};
Common convention:
structfor plain data aggregates (no invariants, all public)classfor objects with behavior and private state that needs protection
Complete Example: Student Grade Tracker
#include <iostream>
#include <string>
#include <vector>
#include <numeric>
class Student {
std::string name_;
std::vector<double> grades_;
public:
explicit Student(const std::string& name) : name_(name) {}
void addGrade(double grade) {
if (grade >= 0 && grade <= 100) {
grades_.push_back(grade);
}
}
double average() const {
if (grades_.empty()) return 0.0;
double sum = std::accumulate(grades_.begin(), grades_.end(), 0.0);
return sum / grades_.size();
}
char letterGrade() const {
double avg = average();
if (avg >= 90) return 'A';
if (avg >= 80) return 'B';
if (avg >= 70) return 'C';
if (avg >= 60) return 'D';
return 'F';
}
void printReport() const {
std::cout << name_ << ": "
<< average() << "% ("
<< letterGrade() << ")\n";
}
const std::string& name() const { return name_; }
};
int main() {
Student alice("Alice");
alice.addGrade(92);
alice.addGrade(88);
alice.addGrade(95);
alice.printReport(); // Alice: 91.6667% (A)
Student bob("Bob");
bob.addGrade(72);
bob.addGrade(68);
bob.addGrade(75);
bob.printReport(); // Bob: 71.6667% (C)
}
Student shows the pieces working together: the grades are private, so the only way in is addGrade, which enforces the 0–100 range — no caller can push -5 into the vector. average() guards against division by zero on an empty list, and is const so printReport() (also const) can call it. There is no destructor or copy code because both members manage themselves (Rule of Zero), so copying a Student just works.
A design point worth noticing: addGrade silently ignores invalid input. That keeps the example short, but in real code a silent drop hides bugs — returning bool like withdraw does, or throwing std::invalid_argument, lets the caller find out. Also, average() recomputes the sum on every call, and printReport calls it twice (once directly, once via letterGrade); for three grades that is irrelevant, and caching it would add state that must be kept in sync, which is the kind of trade-off encapsulation lets you change later without touching callers.
When to write a destructor or copy operations yourself
A useful rule for your first classes is to write as few special member functions as possible. If every member manages itself (std::string, std::vector, std::unique_ptr, plain numbers), the compiler-generated constructor, copy, move and destructor already do the right thing. This is the Rule of Zero, and most classes should follow it.
The moment you write a destructor to release something, such as delete[] on a raw pointer, the generated copy operations become wrong: they copy the pointer, and two objects will free the same memory. At that point either implement or delete the copy constructor and copy assignment (the Rule of Three), or, better, replace the raw pointer with a member that manages itself so you can delete the destructor again. The double free from the shallow copy example above is what happens when this step is skipped.
Frequently Asked Questions (FAQ)
Q. Why are my members initialized in a different order than my member initializer list?
A. C++ always initializes members in the order they are declared in the class, not in the order you list them after the colon. If one member’s initializer uses another member that is declared later, it reads an uninitialized value. GCC and Clang warn about this with -Wreorder (enabled by -Wall). The simple fix is to write the initializer list in declaration order and avoid initializers that depend on other members.
Related Articles
- C++ if and switch Pitfalls
- C++ Functions: Parameters, Return Values, Overloading, and
- set vs unordered_set in C++