Introduction
With encapsulation we hide the implementation details of a type in our code. The implementation details can be data or behavior and there is limited access or no access to these by other types in the code. The purpose of encapsulation is to prevent the change of those behaviors and data from other types as well as to make any changes to them safe for the rest of the code, because of the limited access.
There is a common misconception about encapsulation, that it is about the encapsulation of data and behaviours of objects. The fact is, that encapsulation is about hiding the implementation details of our types, but the objects have full access to the encapsulated members. Here is an example:
public class Character
{
private int _health = 10;
public void Attack(Character opponent) => opponent._health--;
public void ShowHealth() => Console.WriteLine(_health);
}
This compiles just fine and if we run:
Character player = new();
Character enemy = new();
player.Attack(enemy);
enemy.ShowHealth();
The result will be: 9
Here we have created two different objects, the player
and the enemy
objects and although they are different, each one can have access to the private field _health
of the other.
The reason is that they are of the same type. Encapsulation is about hiding implementation details between our types, not our objects. If two objects are of the same type, each one can have access to the other’s internals.
This makes sense. Encapsulation helps programmers not to make mistakes and easily change their implementations without affecting other parts of the code. Objects are instances of types and are created and managed by the machine at runtime. There is no need for encapsulation between objects of the same type, because the machine doesn’t make mistakes. Humans do.
A type in C# (and other OOP languages) is represented by a class. But what exactly defines a type?
What is a type
Let’s say that I give two numbers: 3
and 2
. This is certainly data, but what type are those numbers?
If I say that these numbers are of type int
then we know that the operation 3/2
will result in 1
and the operation 3+2
wil have a result of 5
.
If I say that these numbers are of type float
then the operation 3/2
will have a result of 1.5
and if I say that they are of type string
then the operation 3+2
will give a result of 32
.
From the above, we can understand that a type is actually defined by its behaviours. The data it has, is irrelevant for its definition. That leads to the conclusion that we can encapsulate data and behaviour separately from each other.
For this reason, we can separate three different kinds of encapsulation:
- Encapsulation of data, that hides our data from everything else.
- Encapsulation of behaviour, that hides the implementations of operations that are being performed on our data and
- Encapsulation of functionality that hides the combinations of our types that perform a certain function for our program from other parts of the program.
Let’s see the benefits of each kind of encapsulation:
Encapsulation of data
First level of encapsulation
Suppose that we have a type that represent a car. This type has the operations that a car can perform and data that these operations use. Among other data and behaviours, our car class, will have a gas value and three operations that can be performed: SetGasAmount
, GetGasAmount
and AddGasAmount
(Actually it will have a lot more, but let’s keep it simple for the example). Then our class could look something like this:
public class Car
{
private int _gas;
public void SetGasAmount(int amount) => Gas = amount;
public int GetGasAmount() => Gas;
public void AddGasAmount(int amount) => Gas += amount;
}
This is a first level of encapsulation. Other parts of our code, cannot access the _gas
field directly but only through the relevant methods. This is so common that C# actually provides a syntactic sugar for the methods that get the value or set the value of a field: Properties.
public class Car
{
private int _gas;
public int Gas
{
get => _gas;
set => _gas = value;
}
public void AddGasAmount(int amount) => Gas += amount;
}
But what we gain from this?
The benefit is that now, any operation that we want to perform when we add, get or set the amount of gas, can happen in the _gas
value, without affecting the rest of our code. For example, if we later had the requirement that the car could never use the last unit of gas, for whatever reason, the change is very easy to implement:
public class Car
{
private int _gas;
public int Gas
{
get => _gas - 1;
set => _gas = value;
}
public void AddGasAmount(int amount) => Gas += amount;
}
In production code, we would also need the necessary checks for negative and maximum values, but you get the idea: Any operation on our data, is hidden from the rest of our code and that makes any change easy, as it won’t affect any other part of our program.
Is this enough? Maybe not. We can encapsulate our data further.
Second level of encapsulation
What will happen, if later we had a new requirement, that now we have a second type of car which uses electricity? Obviously naming electricity as _gas
would be confusing, but not only that. The electric car, can use all the available electricity it has, even the last unit.
Remember before that we defined what are types? That leads us to the conclusion, that the data is not necessarily part of the type. A better encapsulation would be to have separate data and behaviour.
A better way to have written the car class would be like this:
public class CarData
{
private int _gas;
public int Fuel
{
get => _gas - 1;
set => _gas = value;
}
}
public class Car
{
private CarData _carData = new();
public int Fuel
{
get => _carData.Fuel;
set => _carData.Fuel = value;
}
public void AddFuelAmount(int amount) => _carData.Fuel += amount;
}
Here we have done two things:
- We have really encapsulated the
_gas
field to anyone who uses the car class. The users of the car class don’t need to know about the internal implementations of the car. They know that a car has fuel, but they don’t need to know what kind of fuel that is. This way, adding a second type of car, the electric car, won’t affect the rest of our code. - We have separated data, from behaviour. Adding now a second type of car, that only differs in data, is easy by creating an abstraction and through composition, bringing in the car class, the appropriate type.
Let’s see how this is done in code:
public interface ICarData
{
public int Fuel { get; set; }
}
public class GasCarData : ICarData
{
private int _gas;
public int Fuel
{
get => _gas - 1; // gas car cannot use the last unit of its fuel
set => _gas = value;
}
}
public class ElectricCarData : ICarData
{
private int _gas;
public int Fuel
{
get => _gas; // electric car can use all its available fuel
set => _gas = value;
}
}
public class Car
{
private readonly ICarData _carData;
public Car(ICarData carData)
{
_carData = carData;
}
public int Fuel
{
get => _carData.Fuel;
set => _carData.Fuel = value;
}
public void AddFuelAmount(int amount) => _carData.Fuel += amount;
}
Here our data is completely encapsulated. We can add different data for the Car
class to use, depending on the type of car fuel we want and the rest of our code knows nothing about it. The rest of the code just keeps using the Fuel
getter and setter and the AddFuelAmount
method as before.
Here we also see another part of the encapsulation: Abstraction.
Abstraction (that in our example is represented with the ICarData
interface) works hand to hand with encapsulation to allow us to hide the internal implementations, in our case the kind of fuel the car uses and how the amount of fuel remaining is calculated differently in each type of car data.
Encapsulation of behaviour
Encapsulation of behaviour is the second type of encapsulation. With encapsulated behaviour, we can hide how something happens in a class. For example let’s suppose that we have a system that is responsible for attacking in a game. It just calls the Attack
method on entities that can attack in our game: Ground enemies, flying enemies and towers.
Each of those attack differently, but the system doesn’t care about how, it only cares that when it calls the Attack
method on something that implements an IAttack
interface, an attack is performed.
For example the ground enemy and the tower classes with the Attack
method could look like this:
public class GroundEnemy : IAttack
{
public void Attack()
{
// Move to the player
// Make attacking sound
// Play attack animation
// Perform attack logic
}
}
public class Tower : IAttack
{
public void Attack()
{
// Check time from last attack
// Load the cannons
// Make attacking sound
// Perform attack logic
}
}
Here the implementations will be different, but the system doesn’t care. An even better way to do this, would be to encapsulate each step of the attack in each class, in its own private method. This way, any change to each step would not have a chance to affect the other steps, as they would be different methods each operating in its own local data.
There are some different benefits to data and behaviour encapsulation:
By encapsulating our data in its own class, we can easily add new behaviour to that class, like the example with the gas fuel that needed to return the gas minus 1, without affecting the rest of our code. In contrast adding data to a data structure, will create the need for changes in whatever class is using that data.
By encapsulating our behaviours, we can easily add new data to the class that contains those behaviours, as any class that uses it, only has need of the behaviours that are defined in the class and the data we add is only used locally for our calculations.
Encapsulation of functionality
The final type of encapsulation, is encapsulation of functionality. By functionality, I mean encapsulating a whole system that is responsible for a certain function and is being used in different parts of our program.
For example, we may have a game that uses random values for creating the levels, calculating the loot, the enemies etc. It may seem fine, to be making these calculations in the relevant locations of our code, but in reality we have duplicated functionality.
Creating a generic system that can handle calculating random values, depending on different inputs (values for levels, enemies, loot etc) and connecting the different parts of our code to this system, can save us time in the long run.
Not only any change we may need to make to the way we calculate random numbers will be concentrated in one place, but also any bug that our code may have, will be in that place and will only need to be fixed once, instead of having to hunt down every class that uses random numbers generation.
Creating a system that implements an abstraction that acts as a connection to all the different parts of our program that need random generation, can be an investment for the future, when a different way for calculations is needed or even for testing, by substituting that system with something deterministic for our play tests.
Conclusion
That’s it about encapsulation. I hope you enjoyed the article. Thank you for reading and as always, if you have any questions or comments you can use the comments section or contact me directly via the contact form or by email. Also if you don’t want to miss any of the new blog posts, you can always subscribe to my newsletter or the RSS feed.