C++ / INHERITANCE AND POLYMORPHISM
Access control in derived classes
Decide what a derived class and its callers may touch, using member access levels, the inheritance specifier, and using-declarations.
What you will learn
- Work out an inherited member's effective access from its base access and the specifier
- Choose protected only when a derived class must maintain that state itself
- Re-expose a single member of a privately inherited base with using Base::name;
- Predict which conversions to a base pointer the inheritance specifier allows
Understanding Access control in derived classes
Two separate decisions control what a derived class can touch. The first is made in the base: each member is declared public, protected, or private, which says who may name it. The second is made in the derivation itself, the specifier in class D : public B, which caps how much of what B exposes reaches D's own users. For anything B declared public or protected the effective access is the stricter of the two; anything B declared private sits outside that arithmetic completely, because no specifier can open it to D.
That last point surprises people, because B's private data is still inherited: it is part of every D object and counts toward sizeof(D). You simply have no name for it. protected is the deliberate opening a base leaves for its derived classes, which makes protected data a promise to every class that will ever derive from you, since any of them can now assign to it without going through your checks. There is fine print too: inside D you may reach a protected member through a D or something further derived, but not through a plain B& and not through a sibling class, because only inside your own branch can the compiler tell that the object's invariants are yours to keep.
The specifier's most visible effect is on the implicit derived-to-base conversion: public inheritance lets any code turn a D* into a B*, protected inheritance narrows that to D and its descendants, and private inheritance narrows it to D's members and friends. That is why private inheritance reads as "implemented in terms of" rather than "is a": the base becomes a hidden implementation detail, and you re-export chosen operations with a using-declaration such as using B::size; in D's public section. Every one of these checks happens at compile time against the name you wrote and the static type you wrote it on, which is also why an override may carry different access from the function it overrides.
<iostream>
class Account {
public:
double balance() const { return balance_; }
protected:
void adjust(double delta) { balance_ += delta; }
private:
double balance_ = 100.0;
};
class Savings : public Account {
public:
void addInterest() {
adjust(balance() * 0.10); // protected: reachable from a derived class
// balance_ += 1.0; // error: private in Account
}
};
int main() {
Savings s;
s.addInterest();
std::cout << "balance: " << s.balance() << "\n";
std::cout << "sizeof(Savings): " << sizeof(Savings) << "\n";
}
An inherited member's effective access is the stricter of its access in the base and the inheritance specifier, and the base's private members are never accessible to the derived class at all.
Worked examples
Private inheritance plus a using-declaration
Hides a whole base behind a derived class and re-exports exactly one of its operations.
<cstddef>
<iostream>
<vector>
class Buffer {
public:
void push(int v) { data_.push_back(v); }
std::size_t size() const { return data_.size(); }
private:
std::vector<int> data_;
};
class Stack : private Buffer { // reuse, not is-a
public:
using Buffer::size; // re-export one name
void put(int v) { push(v); }
};
int main() {
Stack s;
s.put(10);
s.put(20);
std::cout << "size: " << s.size() << "\n";
// s.push(30); // error: push is private in Stack
// Buffer* b = &s; // error: Buffer is an inaccessible base of Stack
}
Example explained
Line 1private inheritance turns every public and protected member of Buffer into a private member of Stack, so push and size start out unreachable from outside.
Line 2using Buffer::size; puts that one name back in Stack's public section; it can restore the member's original access but never raise it above what Buffer granted.
Line 3put(v) calls push(v) freely, because members of Stack still see the base's public members.
Line 4Buffer* b = &s; is rejected even though the Buffer subobject is really there: the conversion is governed by the accessibility of the base.
protected works through your own type only
Shows that a protected member is reachable through a derived object of your own class but not through the base or a sibling.
<iostream>
class Node {
protected:
int weight_ = 1;
};
class Red : public Node {
public:
void set(int w) { weight_ = w; }
int weight() const { return weight_; }
void absorb(Red& other) { // Red&: allowed
weight_ += other.weight_;
other.weight_ = 0;
}
// void absorb(Node& other) { weight_ += other.weight_; } // error
};
class Blue : public Node {
public:
// void raid(Red& r) { weight_ += r.weight_; } // error: other branch
};
int main() {
Red a, b;
a.set(3);
b.set(4);
a.absorb(b);
std::cout << a.weight() << " " << b.weight() << "\n";
}
Example explained
Line 1Inside absorb, other.weight_ compiles because other is a Red&, the very class that was granted protected access.
Line 2The commented overload taking Node& fails: the compiler cannot know that object is a Red, and Red's grant does not extend to arbitrary Nodes.
Line 3Blue::raid fails for the same reason even though weight_ comes from the shared base; protected is per-branch, not hierarchy-wide.
Line 4weight_ is written directly here, which is exactly the coupling that makes protected data a costly choice.
Access is checked on the static type
An override declared private is still callable through a reference to the base where the function is public.
<iostream>
class Task {
public:
virtual void run() { std::cout << "Task::run\n"; }
};
class Secret : public Task {
private:
void run() override { std::cout << "Secret::run\n"; }
};
int main() {
Secret s;
Task& t = s;
t.run(); // allowed: checked against Task::run
// s.run(); // error: Secret::run is private
}
Example explained
Line 1t.run() looks the name up in Task, the static type of t, and Task::run is public, so the access check passes.
Line 2The call still lands in Secret::run: which function runs is settled at run time, while who is permitted to write the call is settled at compile time.
Line 3s.run() is rejected because lookup starts in Secret, where run is private.
Line 4Tightening the access of an override therefore hides nothing from a caller holding a Task&, which makes it a misleading habit.
Effective access under each specifier
Compares what a grandchild class can reach when the same base is inherited publicly, protectedly, and privately.
<iostream>
class B {
public:
int p = 1;
protected:
int q = 2;
};
class Pub : public B {};
class Prot : protected B {};
class Priv : private B {};
class GrandPub : public Pub {
public:
int sum() { return p + q; } // p public, q protected
};
class GrandProt : public Prot {
public:
int sum() { return p + q; } // both protected here
};
class GrandPriv : public Priv {
public:
int sum() { return 0; } // p and q are private in Priv
};
int main() {
GrandPub a;
GrandProt b;
GrandPriv c;
std::cout << a.sum() << " " << b.sum() << " " << c.sum() << "\n";
std::cout << a.p << "\n"; // only this one is public outside
// std::cout << b.p; // error: protected in Prot
}
Example explained
Line 1In GrandPub both names survive with their base access, so p + q compiles and outside code may still read a.p.
Line 2In GrandProt the public p was demoted to protected, so the member function reads it but main cannot.
Line 3GrandPriv sees neither name: private inheritance made them private members of Priv, and a class's private members stop at that class.
Line 4b.sum() returns 2 only because q started at 2; the point is that the demotion changed who may name it, not its value.
Important notes
Name lookup runs before the access check, so any member named f in D hides all of B's f overloads; if D::f is private the call is an error rather than quietly falling back to B::f, and using B::f; brings the base overloads back.
Access control is a compile-time convention, not a runtime barrier: private members still occupy space and can be reached through casts, and friendship is neither inherited nor transitive, so a friend of B is not a friend of D.
Common mistakes
Writing class Stack : Buffer and expecting is-a behaviour: for class the default is private inheritance, so passing a Stack where a Buffer& is wanted fails with "Buffer is an inaccessible base of Stack", while the same declaration written with struct would have compiled.
Making data members protected so subclasses can just use them: every derived class then depends on that representation and can write to it without passing your checks, so you can no longer rename it or add validation.
Expecting a using-declaration or a public section in the derived class to open up the base's private members; using B::data_; is itself an error, and the only fix is a protected accessor added to the base.
Try it yourself
Change, predict, then run
Write class Timer with a private ticks_, a protected bump(int), and a public ticks(); derive Stopwatch publicly and call bump from a lap() method. Then add a line in lap() that assigns to ticks_ directly and read the compiler error it produces.
Open the C++ workspaceCheck your understanding
Given class Engine { public: void start(); }; and class Car : private Engine {};, a member function of Car can write Engine* e = this;, but the identical conversion inside main() is rejected. Why?
- Private inheritance removes the Engine subobject, so there is nothing for the pointer to point at.
- The conversion needs Engine to declare at least one virtual function.
- The derived-to-base conversion is itself subject to an access check, and a private base is visible only to Car's own members and friends.
- It fails because start() is not virtual, so no base pointer can be formed.
Show answer
Accessibility of the base controls the implicit Derived*-to-Base* conversion, so with private inheritance only Car's members and friends can perform it. The first option is tempting but wrong: private inheritance changes visibility, not layout, and the Engine subobject is still present and still counted in sizeof(Car); virtualness has nothing to do with forming a base pointer.