Programming Languages · Objects and Modules
What a Private Field Actually Hides
How access control is checked, why private members remain reachable through reflection or linking tricks, and why the module rather than the class is the enforceable boundary.
Access control is usually described as a property of the class: private members are visible only inside the class body, protected members also to subclasses, public members to everyone. That description is accurate about what the compiler accepts, and misleading about what the running program can do. The distinction matters because encapsulation is used for two different purposes — keeping an invariant that the implementation relies on, and keeping a name out of the published interface — and only the second is reliably enforced.
The check happens in the front end
In a statically compiled language, access modifiers are checked during name resolution, before any machine code is produced. The compiler refuses to compile a reference to a private member from outside its permitted scope. Once the program is compiled, the restriction is gone: the member exists at a fixed offset in the object or at a known symbol in the binary, and nothing at run time re-checks who is calling.
The consequences follow directly. A private field is not encrypted, not hidden from a debugger, and not inaccessible to code that computes its address. In the C++ case the offset of a private data member is deducible from the class definition that the client can still see, since the layout does not depend on access. In the Java case the member is present in the class file’s constant pool and can be read through the reflection API, which performs its own access check, or through low-level mechanisms such as VarHandle or MethodHandles.Lookup with privileged access, which can bypass the language-level modifier. Neither is a bug in the language; both are the predictable result of checking at compile time only.
This is also why the modifiers are not a security mechanism. Any boundary enforced solely by the compiler can be bypassed by a program that does not go through the compiler: a hand-written binary, a language runtime with a reflection facility, or a linker that resolves a symbol by name. Treating private as a security control produces designs that are secure against mistakes and not against adversaries.
Friends, packages, and the scope that owns the name
C++ adds a second mechanism because the class alone is too small a unit. A friend declaration grants a named class or function access to the private members of the declaring class. The design problem it solves is operator overloading and symmetric interfaces: a free function that implements operator<< for a stream cannot be a member of the class it prints, so it needs explicit access. The cost is that friendship is granted by the class being opened, is not inherited, and is not transitive, which keeps it narrow but also means a cluster of cooperating classes must list each other pairwise.
Java takes a different second unit: the package. A member with no modifier, conventionally called package-private, is visible to every class in the same package. This makes the package the natural home for implementation classes that cooperate closely, and it is why the standard library exposes public interfaces in one package while implementation types sit in another. The unit is coarser than C++ friendship and finer than public, but it is also fragile in a specific way: because packages are named by strings and can be split across jars, a package is not a closed unit unless the class loader enforces it, and the module system added later to the platform exists largely to restore that closure.
Rust inverts the default. Items are private to their module unless marked pub, and pub(crate) or pub(in path) widen visibility to a subtree. Submodules can see their ancestors’ private items but not the reverse, which gives a tree-shaped visibility lattice that matches the module tree. There is no per-class access control at all; fields of a struct are visible wherever the struct is visible unless the struct’s fields are individually restricted. The design decision is that the module, not the type, is the unit of encapsulation, and that a type’s invariants are protected by keeping its fields private to the module that defines it and exposing constructors and methods that maintain them.
That choice has a cost worth naming. Two types defined in the same module cannot hide anything from each other, so a module that grows large loses the internal boundaries a class-based design would provide. Rust’s answer is to split modules, which is cheap, rather than to add friend declarations, which are not.
Visibility is not the same as substitutability
A protected member is visible to subclasses, and this is where access control and inheritance interact badly. Granting a subclass access to a field also grants it the ability to depend on that field’s representation, which is exactly the coupling the field was meant to prevent. The base class can no longer change the field’s type or meaning without breaking every subclass in every downstream codebase, and unlike a public member, the breakage is invisible to clients that only use the base class. Languages that offer protected state therefore tend to recommend protected methods over protected fields, and languages designed later often omit protected fields entirely.
The same tension appears in the choice between inheritance and interface implementation. A class that implements an interface exposes behaviour without exposing representation, so the implementer’s fields stay private. A class that extends a base class with protected state takes on a representation commitment. The first is a contract about what an object does; the second is a contract about how it is built, and access modifiers do not distinguish between them.
The boundary that survives compilation
Module systems enforce visibility at a level the compiler cannot be talked out of, because the module is also the unit of separate compilation and of linking. If a module exports a name, other modules can reference it; if it does not, the name may not be emitted as a linkable symbol at all. This is the difference between a convention and a boundary: the class-level modifier leaves the symbol in the binary, while the module-level one can remove it.
The practical test is what a downstream program can do without recompiling the library. It can call any exported symbol whose signature it can reconstruct, regardless of which class declared it. It cannot call a symbol that was never exported. That is why opaque types — a struct whose fields are not part of the module’s interface — are stronger than a class with private fields: the client does not merely lack permission to read the fields, it lacks the definition needed to compute their offsets.
Languages express this in different ways. An ML module signature can hide a type’s representation entirely, so the type is abstract outside the signature and no amount of reflection reveals a structure the compiler never emitted; the sealing rules that make this precise are the subject of applicative and generative functors. A C header can declare a struct with an incomplete type and define it only in the implementation file, so clients allocate pointers rather than values. A Java class cannot do this, because the class file carries the full field list and the virtual machine needs it to allocate instances.
Failure modes at the boundary
Serialisation is the most common leak. A library that writes an object’s fields by reflection, or a serialiser that reads private state, makes every private field part of the persistent format. The class can no longer change its representation without a migration path, and the compiler will not warn, because the dependency was never expressed in source.
Testing is the second. Tests placed inside the same package or module reach package-private members, which encourages tests that assert on internals and therefore freeze the representation. The test suite becomes a second client of the private interface, and refactoring costs twice as much. Moving such tests outside the boundary is the usual remedy, at the price of testing through the public interface only.
Inlining and dead-code elimination are the third. A compiler that can see the whole program may inline a private method and delete its symbol, which is fine, but the same optimisation applied to a public method changes what a separately compiled client can link against. The rule that emerges is that visibility modifiers say who may name a member, while linkage and optimisation decide whether the member exists in the artefact. The two must agree, and in systems where they do not, the failure appears at link time or, worse, at run time when two definitions of the same name are merged.
Choosing the unit
The class-level modifier is convenient when the unit of cooperation is a small group of types, and it is cheap because it needs no separate compilation story. The module-level modifier is stronger and composes better across compilation units, at the cost of making the module the only meaningful boundary. Most large systems end up relying on the module or package boundary for anything that must hold, and using class-level privacy for invariants that only need to survive ordinary maintenance. The two mechanisms are not redundant; they protect against different classes of mistake, and neither protects against a program that deliberately bypasses the compiler. The next layer of the same question is what happens when a module’s interface changes after clients have been compiled against it, which is the subject of the fragile base class problem.
References
- The Java Language Specification: Access Control — Oracle
- The Programming Language Pascal (Revised Report) — Niklaus Wirth, Acta Informatica, 1973
- The Rust Programming Language: Paths for Referring to an Item in the Module Tree — The Rust project