A C++ class declares every data member private, yet adding one more private member forces every translation unit that includes the header to recompile, and in a shipped shared library it breaks binary compatibility. An OCaml module, by contrast, can publish `type t` in its .mli with no representation at all. Explain how a type can be completely access-controlled and still hide nothing, and what techniques restore the secret.
answer
- private = who may name it; hiding = who may depend on it
- C++ layout lives in the header -> recompile + ABI
- pimpl / opaque pointer restores the secret
- OCaml .mli `type t` with no definition
- Go unkeyed literals; Rust #[non_exhaustive]
basics
~20 sAccess control governs who may name a member; hiding governs who may depend on it. C++ puts private members in the header, so their existence, size and layout become client dependencies. OCaml signatures, Ada private types and the pimpl idiom withhold the representation itself.
solid answer
~1 minThe operational test for hiding is: **can I add a field without breaking you?** Identical-looking `private` gives different answers. - **C++**: the full class definition, private members included, must be visible to compile any client, because the compiler needs `sizeof` and layout. `private` restricts naming, not dependence — add a member and every including translation unit recompiles; ship a `.so` and the ABI breaks. The pimpl idiom (an opaque pointer to a struct defined only in the .cpp) restores the secret at the price of an indirection and a heap allocation. - **OCaml / Standard ML**: the `.mli` may declare `type t` with no definition. Clients cannot know whether it is an int or a record, cannot pattern-match it, and never recompile for a representation change. Ada's private part and Modula-2's opaque types do the same in the language. - **Java**: adding a private field is a binary-compatible change, so clients neither recompile nor relink — the same source-level keyword buys strictly more hiding than in C++. - **Go and Rust** show that *construction* is a dependency too: adding a field to a Go struct breaks downstream unkeyed composite literals, which is why Rust offers `#[non_exhaustive]` to forbid downstream construction and exhaustive matching outright and keep the field list changeable.
code
ocaml · 8 lines(* counter.mli -- everything a client sees *)
type t
val make : unit -> t
val bump : t -> t
val value : t -> int
(* counter.ml may switch from an int to a record
without recompiling a single client. *)go deeper
Know that private controls who may write the member's name, and that in some languages the member is still visible in the header the client compiles against.
Be able to explain why adding a private member to a C++ class triggers a wide rebuild, and name at least one language where the representation is genuinely absent from the client's view.
Drive the answer with the operational test — can I add a field without breaking clients? — and pick a restorative technique with its real cost: pimpl, abstract interface plus factory, or an opaque module type.
Tie it to release engineering: your semantic-versioning promise is exactly the set of decisions you actually hid, so choose module boundaries and attributes such as #[non_exhaustive] before the first release, not after.
## Access control is not the same as a dependency boundary An access modifier answers a syntactic question: may this expression mention that member? Information hiding answers a coupling question: if I change the decision behind that member, whose code has to change, recompile, relink, or redeploy? A language can answer the first question strictly and the second question badly. ## Why C++ `private` hides less than it looks A C++ compiler must know the complete layout of a class to allocate it on the stack, embed it by value in another class, or inline a member function. That layout lives in the class definition, and the class definition lives in the header every client includes. Private members are therefore *visible to the compiler in the client's translation unit* even though the client may not name them. Three consequences follow. 1. **Compile-time coupling.** Add a private member and every translation unit that includes the header rebuilds. On large codebases this is the dominant build-time cost and the reason header hygiene is a first-class engineering concern. 2. **ABI coupling.** `sizeof` changes and member offsets shift. A shipped shared library whose header changed but whose consumers were not rebuilt has undefined behaviour: callers allocate the old size and index the old offsets. 3. **Leaked decisions.** The header reveals which container you used, whether you keep a mutex, and whether you cache — all changeable decisions Parnas would want secret, and all now public knowledge even though no client may write them. ### Restoring the secret in C++ The classic fix is the *pimpl* (pointer to implementation) idiom: the public class holds a single `std::unique_ptr<Impl>` and `struct Impl` is defined only in the .cpp. Now the header exposes one pointer-sized member forever; the representation is genuinely secret and can change with a recompile of one file. The cost is honest and worth stating: an extra indirection on every member access, a heap allocation per object, loss of inlining across the boundary, and a hand-written destructor because the deleter needs the complete type. Abstract interface classes with a factory function achieve the same thing through virtual dispatch, trading the allocation for a vtable lookup. ## Languages where the signature is the boundary Standard ML and OCaml separate an implementation from its signature. An `.mli` file can say `type t` with the right-hand side omitted. Clients then know only that the type exists; they cannot construct it except through exported functions, cannot pattern-match it, and cannot depend on whether it is an integer, a record, or a closure. Change the representation and only the implementation file recompiles. This is information hiding as a first-class module feature rather than a per-member permission, and it is why the ML tradition talks about *abstract types* rather than private fields. Ada's private types and Modula-2's opaque types make the same promise inside a class-free module system. Ada is a useful nuance: because the compiler still wants a size for stack allocation, the full definition must appear in the *private part* of the package specification — visible to the compiler, forbidden to the client — a deliberate compromise between C++'s full exposure and ML's total abstraction. ## Java: the same keyword, more hiding On the JVM a client compiles against a class file and links symbolically by name at run time. Adding a private field is defined as a binary-compatible change: existing clients keep working without recompiling, because nothing in their code refers to an offset. So a Java `private` field is genuinely a secret at both compile time and link time — while remaining fully readable through reflection, which is why hiding on the JVM is a *compile-and-evolve* guarantee and never a security boundary. ## Construction is a dependency too Go makes a subtler point. A struct's lowercase fields are unexported, yet the exported struct type can still be constructed by clients with an unkeyed composite literal, `pkg.Config{1, 2}`, which lists the fields positionally. Add a field and that client stops compiling. This is why Go style mandates keyed literals and why some library types embed an unexported zero-sized field specifically to make unkeyed literals impossible. Rust encodes the same insight as an attribute. Marking a struct or enum `#[non_exhaustive]` forbids downstream crates from constructing it with a literal and from matching it exhaustively, so the author reserves the right to add fields or variants later without a semver-major bump. It is the clearest existing statement in a mainstream language that *the ability to construct or exhaustively destructure a value is part of the interface*, quite apart from field visibility. ## What to say when asked Say that `private` is a naming rule, then give the test — can I add a field without breaking a client? — and walk the answers: C++ no (header layout), Java yes (binary compatible), Go only if the client used keyed literals, Rust yes if you declared `#[non_exhaustive]`, OCaml yes by construction. Then name the restorative techniques: pimpl, abstract interface plus factory, module signature, opaque type.
- What does the pimpl idiom actually cost, and when would you refuse to pay it?Every member access goes through a pointer indirection, each object costs a separate heap allocation with worse locality, member functions can no longer be inlined across the boundary, and you must write the destructor out of line because the deleter needs the complete type. Refuse it for small value types on a hot path — a 2D point or a duration — where the object is copied constantly and the representation is not a secret worth keeping. Accept it at library boundaries where you ship a binary and want to change internals without a rebuild of the world.
- Why does Rust offer `#[non_exhaustive]` when its struct fields can already be private?Private fields stop downstream code from reading and writing members, but they do not by themselves stop a downstream crate from exhaustively matching an enum's variants, and for a struct with all-public fields nothing stops literal construction. `#[non_exhaustive]` targets those two remaining dependencies: downstream crates must use a constructor and must include a wildcard arm, which lets the author add a field or a variant in a minor release. It is a declaration that the shape of the value, not just its contents, is an implementation detail.
saying these in an interview costs you the question
- Assuming that because a member is private, no client depends on it — in C++ every including translation unit depends on its size and offset.
- Treating a header change as source-compatible-therefore-safe for a shipped shared library; ABI compatibility is a separate, stricter question.
- Believing pimpl is free, or applying it uniformly to small value types on hot paths.
- Thinking Go's unexported fields prevent construction — an exported struct with an unkeyed composite literal still breaks when a field is added.
- Confusing Java's binary compatibility guarantee with runtime protection: reflection reads private fields regardless.