Encapsulate
what varies
This article is motivated by a technical discussion that I
happened to be part of, a couple of days back. It is about exploring the
application of a very fundamental design principle which is "Identify the
aspects of your application that vary and separate them from what stays the
same". This is one principle that is at the heart of several other design
principles and design patterns.
The real world problem that we discussed is about implementing a
function to map student marks to grades. A student may score marks from 0 to
100 in an exam. The grade scheme is as follows:
Marks Grade
00 - 09 F
10 - 19 F
20 - 29 F
30 - 39 F
40 - 49 F
50 - 59 E
60 - 69 D
70 - 79 C
80 - 89 B
90 - 100 A
The requirement is to write a C++ function that accepts the
students Mark as argument and returns the corresponding Grade.
std::string GetGrades(int Marks)
There are many many ways of doing this. One of the approaches
involves writing a 'if then else' loop as shown. An alternate approach uses
switch-case statements.
#include <iostream>
#include <string>
std::string GetGrades(int Marks)
{
std::string Grade = "Grade Not Found";
if(Marks >= 0 and Marks < 10) Grade = "F";
else if(Marks >= 10 and Marks < 20) Grade = "F";
else if(Marks >= 20 and Marks < 30) Grade = "F";
else if(Marks >= 30 and Marks < 40) Grade = "F";
else if(Marks >= 40 and Marks < 50) Grade = "F";
else if(Marks >= 50 and Marks < 60) Grade = "E";
else if(Marks >= 60 and Marks < 70) Grade = "D";
else if(Marks >= 70 and Marks < 80) Grade = "C";
else if(Marks >= 80 and Marks < 90) Grade = "B";
else if(Marks >= 90 and Marks <= 100) Grade = "A";
return Grade;
}
int main()
{
std::cout << Grades(99) << std::endl;
}
What is your initial thought on this implementation? What are its
strengths and weaknesses? Specifically, does this design comply with
'Encapsulate What Varies principle'? What do you think about the nested 'if
else' logic implementing the mapping algorithm?
Immediately, one thing that is apparent is that the function
'GetGrades' can be considered to be doing two things. It determines if the
input 'Marks' falls within a certain range. It also determines what 'Grade'
to return. This means that this function changes when either of the above
requirements (a.k.a business rules) change.
To appreciate the inherent problem in this code, let's consider
that the mapping of Marks to Grades changes, and Marks between 40-49 are also
considered as "E". Obviously our 'GetGrades' function changes. If the
'Mark' ranges are now required to be in steps of 20 instead of 10 (with
corresponding adjustments in the 'Grades'), our 'GetGrades' function needs modification.
Whenever a module changes for more than one reason, it strongly
indicates a suboptimal design.
Is it possible to make the logic inside of 'GetGrades' more robust
in the event of change of the mapping of Marks to Grades? Can we make
'GetGrades' more maintainable?
Let's look at the implementation more carefully.
First, let's look at what varies.
Here, there are two things which vary.
-The Mark Range identified by beginning of a mark range and end of
the mark range, and
-The mapping of the mark range to Grade
Now, let's look at what does not vary?
The logic to map Marks to a Grade does not vary. The logic simply
checks if the input Mark falls in a certain range and returns the appropriate
Grade.
So, now let us look at how to separate them out to comply
with 'Encapsulate what varies' principle.
Firstly, let us use std::pair<int, int> to denote the
concept of a Mark Range.
typedef std::pair<int, int> MarkRange;
and secondly, let's denote the concept of Grades by a simple
typedef as shown
typedef std::string Grade;
So, now our problem statement now boils down to 'Given a
MarkRange, Retrieve the Grade'. This very strongly calls out for an association between MarkRange and Grade. This can be depicted in our code using an associative STL container e.g. std::map.
typedef std::map<MarkRange, Grade> MarkToGrade;
The client code can statically (or dynamically in a for loop e.g.)
initialize an object of type MarkToGrade as shown below (C++11).
MarkToGrade Grademap {
make_pair(std::make_pair(0, 9), "F"),
make_pair(std::make_pair(10, 19), "F"),
make_pair(std::make_pair(20, 29), "F"),
make_pair(std::make_pair(30, 39), "F"),
make_pair(std::make_pair(40, 49), "F"),
make_pair(std::make_pair(50, 59), "E"),
make_pair(std::make_pair(60, 69), "D"),
make_pair(std::make_pair(70, 79), "C"),
make_pair(std::make_pair(80, 89), "B"),
make_pair(std::make_pair(90, 100), "A"),
};
We are now almost done. All that is need is to rewrite our
immutable logic to determine the 'Grade' from 'MarkRange' (C++11)
Grade GetGrades(int Marks)
{
Grade g = "Grade Not Found";
for(auto const &entry : Grademap)
{
if(Marks >= entry.first.first && Marks <= entry.first.second)
{
g = entry.second;
break;
}
}
return g;
}
Notice, that the implementation is now much more robust in the
event of a change. The function 'GetGrades' does not change if the Mark to
Grade mapping is altered. What changes is just the 'Grademap'. We say that
'GetGrades' is open for extension but closed for modification.
By encapsulating what varies and separating it out from what
remains same, we have effectively achieved better organized code. We comply
strongly with Single Responsibility Principle and with Open Closed principle.
Here is the full listing of the code.
#include <iostream>
#include <map>
#include <string>
typedef std::string Grade;
typedef std::pair<int, int> MarkRange;
typedef std::map<MarkRange, Grade> MarkToGrade;
MarkToGrade Grademap {
make_pair(std::make_pair(0, 9), "F"),
make_pair(std::make_pair(10, 19), "F"),
make_pair(std::make_pair(20, 29), "F"),
make_pair(std::make_pair(30, 39), "F"),
make_pair(std::make_pair(40, 49), "F"),
make_pair(std::make_pair(50, 59), "E"),
make_pair(std::make_pair(60, 69), "D"),
make_pair(std::make_pair(70, 79), "C"),
make_pair(std::make_pair(80, 89), "B"),
make_pair(std::make_pair(90, 100), "A"),
};
Grade GetGrades(int Marks)
{
Grade g = "Grade Not Found";
for(auto const &entry : Grademap)
{
if(Marks >= entry.first.first && Marks <= entry.first.second)
{
g = entry.second;
break;
}
}
return g;
}
int main()
{
std::cout << GetGrades(99) << std::endl;
std::cout << GetGrades(9) << std::endl;
}
Obviously, this
implementation is not perfect and has lots of scope of improvement. As an example, the design can be refactored to use an associative container such as the C++11 std::unordered_multimap which is more suited for faster searches (as we can expect more searches than insertion/deletion in our problem domain). Nevertheless, the solution does highlight the refactorings which are relevant to the topic of this discussion 'Encapsulate what varies'.
Let me know your thoughts and comments.