| Lesson 9 |
Private and public access |
| Objective |
Limiting access of members using private keyword. |
C++ Private and Public Access (Access Specifiers and Information Hiding)
The concept of struct is augmented in C++ to allow functions to have public and private members. Inside a struct, the use of the keyword private followed by a colon restricts the access of any members that come after the keyword. Let us modify our running example of ch_stack to hide its data representation and explore how private works — we will make the member functions public and the data members private. Before examining the syntax, it is worth understanding why access control exists at all. Without it, any part of a program can reach into a data structure and modify its internal state directly — setting top to an arbitrary value, writing past the end of the array, or leaving the stack in an inconsistent state that causes a crash far from the point of the error. Access specifiers are the mechanism by which C++ enforces the boundary between a type's interface — what callers are allowed to do — and its implementation — how the type does it internally.
The Three Access Specifiers
public — The External Interface
Members declared after the public: label are accessible from anywhere — from within the class itself, from derived classes, and from any external code that holds an instance of the type. The public section defines the type's contract with its callers: every function listed there is a capability the type promises to provide, and every caller can rely on those functions remaining available even if the internal implementation changes completely. For ch_stack, the public interface is push(), pop(), top_of(), reset(), empty(), and full() — six operations that callers can use without knowing anything about the internal array or the integer tracking the top position.
private — Hidden Implementation Details
Members declared after the private: label are accessible only from within the class itself — from its own member functions and from explicitly declared friend functions or classes. External code cannot read or modify private members directly. For ch_stack, the private section contains the character array s[], the integer top, and the internal constants EMPTY and FULL. External code cannot write stack.top = 5 or stack.s[0] = 'x' — those operations are compile-time errors. This is not a security mechanism; it is a contract enforcement mechanism. The compiler rejects code that violates the access boundary, making it impossible to accidentally corrupt the internal state of a well-designed class.
protected — Inheritance Access
Members declared after the protected: label are accessible from within the class and from any class that derives from it, but not from external code. Protected is the access level designed for inheritance hierarchies — it allows a base class to expose implementation details to its subclasses without making those details part of the public interface. A stack that could be subclassed into a bounded stack, a thread-safe stack, or a logged stack might declare its internal array and index as protected rather than private, allowing derived classes to access the storage directly. For the ch_stack example, which is a self-contained type with no planned subclasses, private is the correct choice. Inheritance and the practical use of protected are covered in later modules.
struct vs class — The Default Access Difference
struct Defaults to public
In C, struct is a plain aggregate of data fields with no access control — all members are accessible from anywhere. C++ preserves this behavior as the default: in a struct, members are public unless an explicit access specifier overrides them. This is why the ch_stack example must explicitly state public: before the member functions — without it, they would still be public by default, but the explicit label makes the intent visible. The private: label that follows then restricts the data members, which is the entire point of the restructuring.
class Defaults to private
The
class keyword in C++ is almost identical to
struct — the only behavioral difference is the default access level. In a
class, members are
private unless an explicit access specifier overrides them. This means the
ch_stack example can be rewritten as a
class by removing the explicit
private: label (since private is already the default) and keeping the explicit
public: label for the interface functions:
// Equivalent using class — private is the default
class ch_stack {
public:
void reset();
void push(char c);
char pop();
char top_of() const;
bool empty() const;
bool full() const;
// everything below here is private by default
static constexpr int MAX_LEN = 40;
private:
enum class Sentinel { EMPTY = -1, FULL = MAX_LEN - 1 };
char s[MAX_LEN];
int top = static_cast<int>(Sentinel::EMPTY);
};
Convention — When to Use Each
The C++ community convention is: use struct for plain data aggregates that carry no invariants — types where all members are public and there is no behavior that must be protected. Use class for types that enforce invariants — types where the internal state must be protected to keep the object in a consistent condition. A Point with x and y coordinates that can be any value is a natural struct. A stack that must never have top outside the range [-1, MAX_LEN-1] is a natural class. The ch_stack example enforces the invariant that top is always within bounds — making class the idiomatic choice in modern C++.
The ch_stack Example — Hiding the Data
The following modernized version of ch_stack applies C++23 best practices while preserving the structure of the original lesson example. The changes from the legacy version are annotated in the comments:
#include <cassert> // required for assert()
static constexpr int MAX_LEN = 40; // replaces: const int max_len = 40 at namespace scope
class ch_stack {
public:
void reset() noexcept { top_ = static_cast<int>(Sentinel::EMPTY); }
void push(char c) {
assert(top_ != static_cast<int>(Sentinel::FULL)); // precondition
s_[++top_] = c;
}
[[nodiscard]] char pop() { // [[nodiscard]]: discarding return value is likely a bug
assert(top_ != static_cast<int>(Sentinel::EMPTY));
return s_[top_--];
}
[[nodiscard]] char top_of() const { // const: does not modify the object
assert(top_ != static_cast<int>(Sentinel::EMPTY));
return s_[top_];
}
[[nodiscard]] bool empty() const noexcept { // const + nodiscard: read-only, result matters
return top_ == static_cast<int>(Sentinel::EMPTY);
}
[[nodiscard]] bool full() const noexcept {
return top_ == static_cast<int>(Sentinel::FULL);
}
private:
enum class Sentinel : int { // enum class replaces: plain enum
EMPTY = -1,
FULL = MAX_LEN - 1
};
char s_[MAX_LEN]{}; // zero-initialized
int top_ = static_cast<int>(Sentinel::EMPTY); // member initializer replaces uninitialized int
};
What the private Section Protects
The private section of ch_stack contains three things: the character array s_[] that stores the stack contents, the integer top_ that tracks the current top position, and the Sentinel enum class that names the boundary values. The invariant of the stack is: top_ must always be in the range [-1, MAX_LEN-1]. If top_ is -1 the stack is empty; if it is MAX_LEN-1 the stack is full; any other value represents a stack with top_ + 1 elements. This invariant is maintained by push() and pop() and can never be violated by external code because top_ is private. External code cannot write myStack.top_ = 999 — the compiler rejects it at compile time with an access violation error, before the program ever runs.
Why the public Interface Is All Callers Need
A caller using ch_stack needs exactly six operations: reset() to initialize, push() to add a character, pop() to remove and return the top character, top_of() to inspect without removing, empty() to check if the stack has elements, and full() to check if the stack has capacity. The caller does not need to know that the implementation uses a fixed-size array, that the index starts at -1, or that the boundary is stored as an enum class. If the implementation were later changed to use std::vector<char> instead of a fixed array, the public interface — all six function signatures — would remain identical. Callers would recompile and continue working without any changes to their code. This is the practical benefit of information hiding: the implementation can evolve without breaking callers.
Modern C++ Access Specifier Practices
const on Read-Only Member Functions
A member function that does not modify the object's state should be declared const — placed after the closing parenthesis of the parameter list. const member functions can be called on const objects and on const references and pointers; non-const member functions cannot. In the modernized ch_stack, top_of(), empty(), and full() are all const because they only read top_ and do not modify any member variables. Failing to mark read-only functions const prevents them from being called through a const ch_stack& parameter, which is the standard way to pass objects to functions that should not modify them.
[[nodiscard]] on Functions Whose Return Value Matters
The [[nodiscard]] attribute, introduced in C++17, instructs the compiler to warn when the return value of a function is discarded. It is appropriate on any function where ignoring the return value is almost certainly a programming error. For ch_stack: calling pop() and discarding the returned character — writing myStack.pop(); instead of char c = myStack.pop(); — silently loses data and is almost always a mistake. Calling empty() or full() and ignoring the result is always a mistake. The [[nodiscard]] attribute on these functions makes the compiler flag the error at compile time rather than leaving a silent bug in the code.
enum class for Internal Constants
The legacy code uses a plain enum to define EMPTY and FULL. Plain enumerators leak their names into the enclosing scope — EMPTY and FULL become names in the class scope that could conflict with other identifiers. enum class (scoped enumeration), introduced in C++11, requires enumerators to be accessed through the enum name: Sentinel::EMPTY and Sentinel::FULL. This eliminates name collisions, prevents implicit conversion to int (which the legacy plain enum allowed silently), and makes the code's intent explicit — these constants belong to the Sentinel type, not to the general class scope.
static constexpr for Class-Level Constants
The legacy code declares const int max_len = 40 at namespace scope — outside the class. This pollutes the enclosing namespace with a name that belongs conceptually to the class. The modern replacement is static constexpr int MAX_LEN = 40 declared inside the class. static means the value is shared across all instances rather than stored per object. constexpr means the value is a compile-time constant usable in array bounds and template parameters. The constant is accessed as ch_stack::MAX_LEN by external code that needs it, or simply as MAX_LEN within the class — scoped to the class where it belongs.
Why Information Hiding Produces Better Software
Protecting Class Invariants
A class invariant is a condition that must always be true for the object to be in a valid state. For ch_stack, the invariant is: top_ is always in [-1, MAX_LEN-1]. Every public member function maintains this invariant — push() asserts that the stack is not full before incrementing top_; pop() asserts that the stack is not empty before decrementing it. Because top_ is private, no external code can bypass these checks. Without private access, a caller could write myStack.top_ = -100, which would cause pop() to return garbage from an out-of-bounds array access — undefined behavior that the C++ standard does not protect against and that produces unpredictable runtime crashes. Private access transforms a runtime problem into a compile-time error.
Decoupling Interface from Implementation
The public interface of ch_stack — its six member function signatures — defines a contract that callers depend on. The private implementation — the fixed-size array, the integer index, the sentinel values — is an implementation detail that callers have no right to depend on. This separation allows the implementation to change without breaking callers. If a later version of the class replaces the fixed array with a std::vector<char> to support dynamic sizing, the public interface remains identical. All callers recompile and work correctly without any changes. If the internal representation were public, any caller that directly accessed s_[] or top_ would break when those names changed or disappeared — creating a maintenance burden that grows with every new caller added to the codebase.
Reducing Coupling Between Components
Coupling measures how much one component depends on the internal details of another. High coupling means a change in one component requires changes in many others — the system is brittle. Low coupling means components can be modified, tested, and replaced independently. Private access specifiers directly enforce low coupling by making it impossible for external code to depend on private details. A ch_stack with a fully private implementation can be replaced, refactored, or optimized without any effect on the code that uses it — because that code can only access the public interface. This is the foundation of object-oriented design: types expose stable interfaces and hide volatile implementations, so the system remains maintainable as it grows in complexity.
In the next lesson, you will learn about the ADT interface and how abstract data types formalize the separation between interface and implementation.
