(Probably)
Encapsulation often comes up in the context of Object Oriented Programming, but is actually an important tool when it comes to programming in general, in any paradigm, and it can be applied at all scales - functions, objects, collaborations of objects, components, modules, services, systems.
The problem is, the way people tend to talk about it can easily leave you with a wrong impression of the core ideas, which leads to designs that feature what I call A Magical Black BoxTM.
In its most extreme form, A Magical Black BoxTM is an inscrutable thing with no inputs or outputs that you call entirely for its side-effects. Yes, good names help here, but we can do better.
DoSomething();
// or
someService.MakeItHappen(); // where all methods on the service are like this
This is not what encapsulation is about.
The term encapsulation often co-occurs with information hiding and abstraction. These are all related ideas, but their meaning is a little fuzzier than one would like when trying to understand these things, and on top of that, there's some conceptual overlap between them. There's a lot of conflicting information and advice out there, and the "best practices" surrounding these ideas have been evolving over time (or even going through historical cycles).
Encapsulation is often described as "bundling functionality and data", which, while not exactly wrong, also misses the point, I think. The problem is, this POV doesn't tell you the "why" of it. It is also said that the bundled functions operate on that encapsulated data, but that's only a part of the story, and isn't the best way to think about this.
Information hiding[1] is talked about in terms of hiding the internal workings of a software component from its clients. But the key concern has to do with deciding what should be hidden (and what should not be), and why. It's not about preventing the users of your software component from having any idea about what is going on, it's about being explicit as to what aspects of your component they can safely rely on, giving you more freedom to evolve the component without breaking their code (for the most part).
Abstraction is a powerful, broad idea that we tend to think about in too narrow a way. Namely, it's not just about abstract classes and interfaces, or even OOP for that matter. In fact, this goes beyond software development and computer science, and is a key tool in many other fields. To paraphrase Dijkstra, it's not about making things unclear, or about sweeping important concerns under the rug, it's about getting to the very essence of a concept or a problem, and then leveraging the features of the language you're working in, so that you can express (in code, documentation, etc.) the solution with precision and relative simplicity. This re-casting will be, of course, still too nebulous for many readers (I'll write about abstraction in a different article), but hopefully it gets you out of the "complex overengineered abstract hierarchies" mindset. That's not what's being advocated for here.
An encapsulated software component, then, should generally not be an inscrutable black box. You will probably have a few of those, but you should confine them to the internals of the code that sits on the very edges of your application - these will be things like calls to external libraries that help deal with various kinds of IO, or things like Application.Run().
Avoid making inscrutable black boxes.
So, if encapsulation is not that, what is it?
Let's talk in OO terms for the sake of simplicity. Let's also assume we're not at the boundary of the system in the aforementioned sense.
Think of a class as of a configurable factory that lets you create a little preconfigured machine that you can pass around, that has internal, private state that controls its inner workings, but that has a well-defined way to interact with, and provide feedback to, the external world.
The second part is very important because it makes it easier for developers who make use of your component to figure out if their own client code does the right thing, and to find bugs if there are any, all in isolation. Therefore, what follows is of particular significance to library implementers, who write tools for other people to use. However, since "a developer using your component" can also be one of your teammates, or you yourself next week (or several months from now), the benefits extend to non-library code too - if you can design well.
This interaction with the external world comes in two flavors. First, it is perfectly acceptable for an encapsulated object's primary purpose to be to process external input. It's the internal state (the "configuration" of our machine) that we want to protect - this doesn't imply that the the data we want to process must also be internal to the object. In this case, the object will be function-like (think something like a closure over a lambda). Sometimes, this is desirable, sometimes it is not.
Which brings me to the second flavor; this time, the primary processing target is indeed internal to the object, but importantly, there are methods and properties that follow certain rules and certain guarantees, that allow for clients to inspect and think about the abstract, externally observable state of the object. This is a bit subtle, so more on that later, but as an example, think about something like an associative container from your favorite library (Map, Hashtable) - while some information about the implementation might be available in the docs, and might inform your choice of container, when it comes to actually writing the client code, you generally don't need to worry about how the data is stored internally, and you instead rely on what we call behavioral guarantees.
E.g. you know that if you add an element under a certain key, you'll get the same element back if you later on request one using the same key. This may seem obvious and almost trivial, but if this rule is all you rely on, then library implementers can change how objects are stored and looked up without breaking your code (precisely because this rule tells you nothing about these things). Another such rule is about what constitutes key equality - and this depends on the chosen key type. Or, there might be a rule that restricts key types to those that are comparable via < (or an equivalent comparison function)[1]. Generally, an encapsulated component will come with a number of such simple rules and guarantees, and perhaps a few more complicated ones - and you'll find these in the documentation if you look carefully. As simple as these may be, if you're the one developing the library, it's surprisingly difficult to come up with a good list of them!
The key point is that in both cases, there should be some way to understand what the object/component does in the abstract (or what the object's current abstract state is), even though the internal state and implementation are hidden.
Your job as a designer of a software component is to provide means by which your users can do this. This can be challenging.
The rest of the article will tackle how to go about doing that.
The external input could be a plain data structure; in that case, a well-designed object will typically do some processing on it, and return some sort of an output, just like a function. Preferably, it'll do it functional programming–style, as a pure function.
Going towards the other end of the spectrum, the input could be a lambda, or a full blown object itself, with a well-defined interface and behavior. Then there will be some sort of an interaction between the objects, one delegating tasks to the other, trusting that the other object will do what it "says on the tin", while keeping its own operation at the appropriate level of abstraction, only dealing directly with its own narrowly defined concerns.
What you are essentially doing when writing client code that makes use of such an object is you're programming this little machine (and perhaps other such machines) to perform some higher-level task. One of the key design concerns is what should go into the "user manual" (the client-facing documentation) for such a machine, and complementary to that, what things about the machine should be deducible by observing it from the outside.
So, there are a number of things here that we need to understand in more detail.
Next time, we'll take a look at ...
[1] The term comes from Parnas (1971), where the author essentially argues for restricting the amount of internal detail that goes into the client-facing documentation of a software component, for the benefit of both the developers who wish to make use of someone else's library, as well as the maintainers of that library. This was contrary to current practices at the time. See also Parnas (1972).
[1] This is what std::map and some other components from the C++ Standard Library do; more precisely, they require a strict weak ordering.
Parnas (1971). Information distribution aspects of design methodology. Tech. Rept., Depart. Computer Science, Carnegie- Mellon U., Pittsburgh, Pa. Also: Proc., IFIP Congress (Ljubljana, Yugoslavia, 1971), pp. 339–344.
Parnas (1972). On the Criteria To Be Used in Decomposing Systems into Modules. Communications of the ACM, Volume 15, Issue 12, pp. 1053 - 1058, https://doi.org/10.1145/361598.361623