As software developers, we spend significant effort understanding and building software to solve complex problems. Any piece of non-trivial software quickly becomes too big to comprehend as a single unit of code. We can only keep so many details in mind. So, how do we deal with our human limitations? In a single word, we use “abstraction,” of course! An abstraction substitutes a simpler, or generalized, idea for a more complex one. Abstractions reduce the number of details we need to keep in mind simultaneously.
We use abstractions at every level of software from functions, classes, libraries, and even large components. For example, a relational database certainly qualifies as a complex piece of code, but unless you are implementing an RDBMS, you can consider it more abstractly as a tool for storing and querying data.
Whether you are an experienced developer, or just starting out in the industry, it is good to remember the fundamental abstraction techniques we use to manage code complexity. Let’s review three of those techniques now.
Object-oriented languages support abstraction with base classes and interfaces that represent a generalization of more concrete subclasses. Inheritance forms an “is-a” relationship where we say, for example, a circle is-a shape.
Simply building a class hierarchy, or implementing an interface, does little to manage complexity on its own, though. The abstraction is effective when client code is able to use the base types without the need to be aware of the details of the concrete subclasses.
Microsoft’s ADO.NET classes for accessing relational databases is a good example of abstraction; these classes are found in the System.Data namespace. One of the base classes, DbConnection, represents an abstract connection to a database and has several subclasses for specific databases. Client code can use this and the other base classes to connect to a database without even knowing the specific database product being used.
Encapsulation helps manage complexity by placing complex code within simpler code. This allows us to hide some or even all of the details of the complex code. Object-oriented languages excel at encapsulation by allowing classes to contain members that can be primitive types, and more importantly, other objects. Similar to how inheritance forms an “is-a” relationship, we say encapsulation forms a “has-a” relationship.
Once again, we need to be careful that encapsulated ideas remain encapsulated by limiting what details the containing class exposes. The fewer details exposed, the more effective the encapsulation will be at managing complexity.
I used encapsulation in a recent project where I needed to implement a message bus that could both send and receive messages. The code to send a message is quite different than the code to receive a message. Instead of implementing both in a single class, I implemented sending and receiving in two separate classes. A third class then encapsulated both to provide a simple message bus API. This made it easier to understand and implement the two aspects of the message bus.
Composition manages complexity by composing functions together, where the output of one function serves as the input to another. Composition manages complexity by letting us think about each function in the composition separately and individually. When functions are composed together, we can think about the new function they form instead of the many individual functions in the composition.
Functional programming languages, and languages that contain features such as lambda expressions, naturally support composition very well. We often think of these languages first, but any procedural programming language, where a function can call another function, is an example of composition.
A great example of function composition that I’ve used quite a bit recently is found in Microsoft’s System.Linq namespace. These functions operate on IEnumerable<T> (which itself is an abstraction!) to filter, sort, map and otherwise process a collection of items. It is easy to compose these relatively simple individual functions to create more complex functions.
Benefits of Abstraction
There are many benefits to using these techniques effectively. Abstraction helps maintain a “separation of concerns,” where code is more loosely coupled and easier to test. In fact, we can state this from the opposite point of view. Code that is easily unit tested is code that makes use of inheritance, encapsulation, and function composition. Specifically:
- We can mock and verify how code interacts with interfaces.
- We can test encapsulated objects and concrete subclasses individually.
- We can test composed functions separately.
These fundamental abstraction techniques are necessary for software development. They promote readable and maintainable code. They allow us to better understand, reason about, and discuss complex software systems in a team environment.