1. Introduction
If you've seen code that uses getName()/setName() for every field, it's easy to think: “Okay, that's just how it's done”. Then you encounter C++ code where instead of getX() they just write x(), and instead of setX() there is a method like moveTo(...), and it's no longer obvious why.
The key idea here is: getters and setters are not magic and not a “mandatory ritual of a grown-up programmer”, but ordinary methods. Their value appears when they help maintain invariants and express the meaning of operations. If they merely mirror field access one-to-one, you get a noisy interface that creates an illusion of “encapsulation” but actually protects nothing — and often makes evolving the class painful.
To keep the discussion concrete, we'll continue with our example console app TaskTracker (a mini task tracker). Previously (at the struct stage) a task could look like: id, title, done. Now we want to take a step toward a “smart object” that does not allow itself to be broken from outside.
2. Getters: “giving read access” is also a responsibility
When we hear “getter”, we usually imagine: “it's to obtain the value of a field”. But in class design a getter is responsible not only for reading, but also for in what form external code will see the data.
This is surprisingly important: the same fact can be “given out” safely or dangerously, cheaply or expensively, conveniently or inconveniently. In C++ we have several options: return by value, return a const T&, and sometimes (very carefully) return a T&. And it's often from getters that the first serious cracks in encapsulation start, because “I just wanted it to be convenient” easily turns into “I accidentally handed out a control lever to the internals”.
Simple safe getter for small types
For small types like int, bool it's almost always convenient and safe to return by value:
class Task {
public:
int id() const { return id_; }
bool is_done() const { return done_; }
private:
int id_ = 0;
bool done_ = false;
};
There are no hidden pitfalls here: int and bool copy quickly, you don't give direct access to internal fields, and invariants are not harmed.
Getter for std::string: value or const&
With strings it gets more interesting. If a task has a title, you can do:
#include <string>
class Task {
public:
std::string title() const { return title_; } // copy
private:
std::string title_ = "untitled";
};
This is safe, but can be expensive: the string is copied. Sometimes that's fine (strings are short, calls are rare). Sometimes you want to read the string without copying — then a return by const-reference is used:
#include <string>
class Task {
public:
const std::string& title() const { return title_; } // no copy
private:
std::string title_ = "untitled";
};
This is still safe for encapsulation because a const std::string& does not allow external code to modify the string directly. But an important nuance appears: the reference is “bound” to the object. If the object is destroyed, the reference becomes dangling.
The rule can be stated simply: do not store the result of a const& getter longer than the lifetime of the object. As long as you use it “inline” — it's fine.
Why a T& getter often breaks a class
Here is a getter that looks “very convenient”:
#include <string>
class Task {
public:
std::string& title() { return title_; } // dangerous
private:
std::string title_ = "untitled";
};
Now external code can do anything:
Task t;
// External code got a mutable reference and bypassed any checks.
t.title().clear();
If your invariant was “the task title is not empty”, it just got broken. Formally the class is “encapsulated” (field is private), but in practice you handed out a direct wire to the internals. This is called “encapsulation leakage”.
Mini-table: how to choose return type for a getter
When you start writing classes, it's useful to have a simple guideline. Not a “law”, but common sense so your brain doesn't stall on every method.
| What we return | When it fits | What you gain | What can go wrong |
|---|---|---|---|
| T (by value) | small types (int, bool) or when a copy is fine | simplicity and safety | can be expensive for large objects |
| const T& | large types (std::string, std::vector) and frequent reads | no copy, still safe | you cannot store the reference longer than the object's lifetime |
| T& | rarely: when you consciously allow external code to modify the internals | maximum “convenience” | almost always breaks invariants and design |
Note: in C++ a getter is part of the API. If you once returned a T&, you have almost promised the class user that they can always modify it directly. Later you may want to add checks… and discover that checks are already bypassed via the T&.
3. Setters and operations: changing state without losing invariants
A setter seems simple: “assign a value to a field”. But in a proper class a setter is a control point: here you decide whether a change is allowed, whether data should be normalized, whether to reject, and how to preserve invariants.
The main criterion: a setter is justified when it expresses rules and meaning. If it simply does field_ = value; without checks, it often becomes “a public field wrapped in two extra screens of code”.
Setter without rules: exists formally, protects nothing in practice
#include <string>
class TaskBad {
public:
void set_title(const std::string& title) {
title_ = title; // no checks
}
const std::string& title() const { return title_; }
private:
std::string title_ = "untitled";
};
What's wrong here? You declared “I control access”, but you don't control it. External code can set an empty string, a string of spaces, an overly long string — anything. The class doesn't protect itself.
Setter with checks: a “setter with character”
Let's make an invariant: a task title must not be empty.
#include <string>
class Task {
public:
bool set_title(const std::string& title) {
if (title.empty()) return false;
title_ = title;
return true;
}
const std::string& title() const { return title_; }
private:
std::string title_ = "untitled";
};
Now the setter is genuinely useful: it doesn't just assign, it prevents the object from becoming invalid.
Note the style: we return bool so external code can see whether it succeeded. We don't use exceptions (that's a separate large topic), so a graceful failure is normal practice here.
Why “get/set for everything” makes a class anemic
The most common beginner mistake sounds like: “Since we have a class and private, let's make get/set for every field”. It looks solid, but if set_* contains no logic and protection of invariants, the class becomes just a noisy version of a struct.
It adds no meaning but adds obligations: now external code depends on dozens of tiny methods, and changing the internal representation becomes harder.
Imagine a task has priority from 1 to 5. If you wrote set_priority(int p) without checks, you allowed setting -100 or 999. Later the program will accumulate workarounds like if (task.priority() < 1) ..., and you lost: the check has spread around the codebase and the class stopped being the owner of its correctness.
Operation-methods instead of setters
A useful habit: when you are about to write a setter, pause and ask yourself: “Is this really a field assignment, or is it an operation?”. In many cases it's clearer and safer to provide an operation-method.
For example, instead of set_done(true) it's often better to have:
- mark_done()
- reopen()
This reads like a command, doesn't require remembering “which value means what”, and allows preserving invariants internally.
4. Practical example: improving TaskTracker without extra get/set
Now let's assemble a more realistic snippet for our TaskTracker. Let a task have:
- id (does not change after creation),
- title (non-empty),
- done (changes only via operation-methods),
- priority (1..5).
Minimal “correct” Task class
#include <string>
class Task {
public:
Task(int id, const std::string& title, int priority)
: id_(id), title_(title), priority_(priority) {
// We won't dive deep into constructors in this lecture, but you've seen the idea.
// Checks can be done here as well, but for now we keep the focus on the API.
if (title_.empty()) title_ = "untitled";
if (priority_ < 1) priority_ = 1;
if (priority_ > 5) priority_ = 5;
}
int id() const { return id_; }
const std::string& title() const { return title_; }
int priority() const { return priority_; }
bool is_done() const { return done_; }
bool rename(const std::string& new_title) {
if (new_title.empty()) return false;
title_ = new_title;
return true;
}
bool set_priority(int p) {
if (p < 1 || p > 5) return false;
priority_ = p;
return true;
}
void mark_done() { done_ = true; }
void reopen() { done_ = false; }
private:
int id_ = 0;
std::string title_ = "untitled";
int priority_ = 1; // invariant: 1..5
bool done_ = false;
};
Note the idea: we did not provide set_id, because id is the identity of the task. If external code could change id, you'd quickly get chaos: a task “was #3 yesterday, #17 today”. Sometimes that's acceptable, but then it's not an id but rather a “position number”, and that's a different entity.
Using the class in a small main()
#include <iostream>
#include <string>
int main() {
Task t(1, "Buy milk", 3);
std::cout << t.id() << ": " << t.title() << "\n"; // 1: Buy milk
t.mark_done();
std::cout << t.is_done() << "\n"; // 1
if (!t.rename("")) {
std::cout << "Bad title!\n"; // Bad title!
}
}
Yes, this looks slightly longer than direct field access. But now the “correctness” is concentrated inside Task, not scattered across the program.
5. Encapsulation leakage: an example that seems harmless
Sometimes you want to “speed up” and “simplify”: for example, give access to a task title as a mutable reference so you can edit it directly. We'll show why this is dangerous specifically for invariants.
Bad variant:
#include <string>
class TaskLeaky {
public:
std::string& title() { return title_; } // handed out control to the outside
const std::string& title() const { return title_; }
private:
std::string title_ = "untitled"; // invariant: non-empty
};
Usage:
#include <iostream>
int main() {
TaskLeaky t;
t.title().clear(); // invariant broken
std::cout << "[" << t.title() << "]\n"; // []
}
The class can no longer guarantee its rule “non-empty”, because you built a bypass tunnel.
Correct variant — provide only read access and an operation to modify:
#include <string>
class TaskSafe {
public:
const std::string& title() const { return title_; }
bool rename(const std::string& new_title) {
if (new_title.empty()) return false;
title_ = new_title;
return true;
}
private:
std::string title_ = "untitled";
};
Now changes are possible only via the control point rename, which means they are checked.
6. Diagram: how a class interface protects state
Sometimes it's useful to see the idea “with your own eyes”:
flowchart TD
A[External code] -->|calls public methods| B[Class public interface]
B -->|checks and rules| C[Private fields]
A -->|if you handed out T& outside| C
The idea is simple: the normal path to change state goes through the public interface where checks live. But if you handed out a mutable reference (T&) to the internals, external code starts going “directly”, and your rules are bypassed.
7. Names of getters and setters: getTitle() vs title()
In different codebases you'll find different styles. In examples we often use the style title() / priority() / is_done() — it's short and reads like a “property” without extra noise. The style getTitle() is also fine, especially if your team or project already agreed on it.
More important is this: the method name should reflect meaning. If you make a method set_title, but inside it also “normalizes” the string (trims spaces, replaces empty with "untitled"), it might be more logical to call it rename or try_set_title so behavior is expected.
Naming a method “set” and then performing “add if missing” internally is another common source of confusion.
When getters and setters are actually appropriate
There are scenarios where getters and setters are an excellent tool and shouldn't be demonized.
A getter is almost always justified if a field is private but you need to provide safe reading. A setter is justified if a field truly has an “assignment mode” and there are correctness rules. In our Task that is set_priority, because “priority” is a configurable value with a clear range. For done we chose operation-methods mark_done/reopen because that reads better and reduces the chance of misinterpreting intent.
8. Typical mistakes
Mistake #1: “get and set for every field because that's how it's done”.
This approach often makes the class noisy and useless: invariants are not protected, there's no logic, and external code starts depending on many trivial methods. If you don't add rules and meaning, it's simpler to keep a struct (where that's honestly visible), or to make a “real” class with operations and checks.
Mistake #2: setters without checks that allow invalid values.
When a method is called set_*, readers expect that after it the object remains valid. If you allow “anything”, checks start proliferating across the project and the class loses its role as the keeper of its invariants. It's much better to reject (for example, return false) than to silently accept garbage.
Mistake #3: a getter returning a T& because “it's more convenient”.
This is a classic encapsulation leak: external code gains the ability to change internals without your rules. It's especially dangerous to return std::string& or std::vector<T>&, because the outside can clear a container, add strange elements, or break relationships between fields. If you need read access without copy — return const T&. If you need mutation — provide a method that expresses the operation and contains checks.
Mistake #4: setters that change the object's “identity”.
Methods like set_id(...) often look fine until you start storing objects in containers, searching them by id, printing reports and linking data. If id changes, you can easily break consistency at the application level. Usually identity is assigned at creation and then not changed.
Mistake #5: getters that return a reference which calling code stores for too long.
Returning const std::string& is convenient and fast, but the reference only lives as long as the object. If someone stores that reference and later the object is destroyed or replaced, you'll get access to “already non-existing data”. In examples it's simpler to follow the rule: use a reference from a getter immediately and don't stash it away; if you need to store — make a copy.
GO TO FULL VERSION