Object Orientation: Maintaining Relationships Amongst Objects
Want to learn how to establish and maintain strong relationships amongst objects in your project? Check out this post to learn more about object orientation.
Join the DZone community and get the full member experience.Join For Free
Any successful programming language lives on long after it is first created. In its lifespan, this language goes through multiple changes that fix different issues as well as adds more and more useful features. In order to go through these changes smoothly, the software should be designed to make it more maintainable and extensible.
The Code Smells! The Code Rots!
Oftentimes, we lack internal quality, and the focus is primarily on "making it work" and not so much on "doing it the right way." Here, I will explain my thoughts on getting software craftsmanship to "do it right" in the object-oriented world.
Why Another Article on OOP?
There is tons of information available on design principles and patterns. Here, the focus will be on programming fundamentals (classes and its relationship). We will attempt to work out design flaws by dealing with these simple elementary concepts, without getting into the list of patterns and principles. During this process, we will begin to understand the kind of relationship that leads to greater flexibility in design. I hope this will give you additional insight while designing or implementing your software.
The content of this post is based on my studies and experience with object-oriented design, as well as some references from the object-oriented design training provided by Subramanian Sivakumar from Pratian Technologies.
Let's begin with chanting what I call as the Bija Mantra of object-oriented programming:
"High cohesion and low coupling!"
Cohesion is all about how different classes are being defined, and coupling is about how different classes are being related. If you get this right, then, most likely, you have created a design that is of great internal quality and is highly maintainable and extensible.
So, the primary design decisions in any object-oriented software development are to:
- Define what constitutes different classes,
- Define relationships between all the classes.
With a comprehensive understanding of the requirements, it is intuitive to come up with a software component design that has a set of classes and relationships in a solution space, which will be in coherence with objects in the problem space. To begin with, the design may be perfect and the software may work great. But, as I said before, any programming language that lives long after it is created requires a lot of changes throughout its life to support new platforms and features. The design of classes and its relationships should not only be in coherence with objects in the problem space, but it should also be flexible enough to support extensibility and maintainability.
The class relationship is the most fundamental and one of the most important decisions that drive extensibility and maintainability of a design. The content here will provide you with some insight into the fundamentals of class relationships and how to achieve design flexibility.
In a procedural programming paradigm, a function or procedure defines a behavior. To get this behavior in an application, the procedure can simply be called, if it is available in the same process space.
Similarly, in an object-oriented programming paradigm, a function or method of a class also defines behavior. But, to get that behavior in another class, unlike procedural paradigm, it is not possible to directly call the method. To do that, first, the two classes must be related.
Here are different ways in which two classes can be related:
Is-A: Should be read as "class A Is-A class B." In this case, any method of class B can be called from class A, as if the method is available in class A itself.
Has-A: This should be read as "class A Has-A class B." This means class A has an object of class B as a member variable. In this case, any method of class B can be called from class A using the class B member variable.
Uses: If class A uses class B, it means that the methods in class A have a local instance of class B, which is used to call a method of class B. This should be used only for localized need.
How Do we Relate Classes?
Given two classes, how do I decide on which relationship to choose? One approach could be to relate classes based on the relationship of corresponding objects in the problem space. But, that isn’t enough, as it doesn’t take extensibility and maintainability into consideration.
To further develop our understanding, here, we will take a problem statement and analyze its evolving design with a focus on maintenance and extensibility. These evolving designs will also give us insight into the usage of these different types of class relationships.
Design reader classes should be able to read from a file, socket, or pipe. It should be able to read byte-by-byte or a number of bytes together. These requirements are just for the first version. We may have more requirements for the future, e.g. there may be more source to read from, or we may want to read data types (
int, etc). Based on what we decide to build in the future, these classes should accommodate the new changes as and when we want to build them.
In this design, the
read() method in the
InputStream class is left for the subclass to implement. And, the
read(buffer : byte ) method is implemented using the
read() method in the
InputStream class. All the subclasses implement the
read() method. The example pseudo code explains how to use these classes.
Overall, this is a good design that uses the Is-A relationship for code reuse. This way, it makes the design extensible, as a new class,
ByteArrayInputStream, can be added here, which has Is-A relationship with
InputStream, and automatically, it will have the
read(buffer) method available. The design is maintainable as it avoids duplicate code for the (buffer) method in each class, thus any change in this method in future would require change only in the
Applications using these libraries may need to read large data. As the read method is reading it byte-by-byte, overall performance will be slow.
The other issue is that it provides methods to read a byte or an array of bytes. Whereas, the application may need to read different data-types and not just bytes. If that facility is not available, then each application will have to do data conversion in their implementation, which would be an overhead.
"A Is-A B" means that class A can reuse code from class B. This also provides extensibility and maintainability.
The solution to any performance problem would be to implement buffering in methods. This buffer is maintained in the class. Each time the read method is called, it looks into the buffer and returns from there. If the data is not available in the buffer, then it fills the entire buffer instead of reading just one byte. Changing the same read method in each class may not be advisable as some applications or customers may not want this overhead of extra memory getting utilized as an internal buffer for these classes. So, look at a design where new classes are introduced to get this new behavior. This will ensure that the application will only need to take care of extra memory requirement, whereas all other application will be unaffected.
Here, we notice that there are two different streams in the design. Some streams will read from different sources and implement the
read method, e.g.,
PipeInputStream. We will call them
BaseStreamclasses. The other type of streams work with base streams and provide extra functionality on top of that, for example,
DataFileInputS, etc. We will call them the
The above design is done using A Is-A B and overcomes the design issues discussed above. We use this Is-A relationship to create buffered and data streams. Pseudo code that uses
BufferedFileInputS shows that the only change required from design #1 is to instantiate
BufferedFileInputS instead of
FileInputStream. This is possible since the Is-A relationship provides type-compatibility.
We realize that to add any new base stream class, we need to create a corresponding decorator stream classes, as well. Things will be more complicated when all these decorator stream classes need to work together. This will lead to an explosion in the number of classes and the extensibility problem in the design. Also, it leads to maintainability problems, as changing the
readBoolean method will require changes in all data related decorator stream classes. This problem is known as Class Proliferation.
Is-A relationship provides type-compatibility, which allows the use of variable implementation in an easier way. Extensive use of Is-A for code reuse may lead to problems like class proliferation.
When we look at it closely, we realize that all buffered and data decorator stream classes do similar things. All buffered decorator stream classes read the buffer, and all data stream classes use the
read() method from the base stream classes and construct different data-types based on the method being called, e.g.
readBoolean() , etc. So, quite clearly, the design should avoid having the repetitive code.
The above design solves the problem of class proliferation. Here, we use the Has-A relationship instead of Is-A. In this design, adding a new base stream class will not require the addition of new decorator stream classes.
When a new base stream class is added, all the decorator stream classes need modification to add a reference to this new class, which is a maintenance problem.
A Has-A B' means class A can reuse code from class B. This provides extensibility and maintainability and also solves the problem of class proliferation.
The above design solves the maintenance problem above. In the new design, all the decorator stream classes keep a reference of
InputStream instead of each base stream classes. So, when a new base stream class is added, it is automatically referenced in decorator stream classes through the
InputStream reference. This design allows all the decorator stream classes to work together, e.g. we can create
DataInputStream, which uses a
BufferedInputStream, which internally uses
FileInputStream. It’s a very flexible design!
If a new decorator stream class is added, it provides extra functionality to the basic stream classes. The designer of the new decorator stream class would need to keep a reference to the
InputStream. A good design should hide these complexities for better extensibility.
A Has-A B' means class A can reuse code from any class of type B. This provides greater extensibility and maintainability.
The above design makes a further improvement over the existing one. With the new design, a new decorator stream class can be added by just extending to the
FilterInputStream, and automatically, it will reference the
InputStream class, and thus, it will have the reference of all the base and decorator stream classes.
A 'Has-A B' means any class of type A can reuse code from any class of type B. This provides greater extensibility and maintainability.
Progression of Thought
If you go through the above five designs, it shows a transition on how Has-A provides better implementation reuse.
'A Is-A B' Class A can reuse implementation of Class B. And, Is-A provides implementation reuse.
'A Has-A B' Class A can reuse implementation of Class B. Has-A also provides implementation reuse. Along with that, it also avoids the class proliferation problem.
'A Has-A B' Class A can reuse any implementation of type B (class B and all its subclasses). Has-A provides variable implementation reuse.
'A Has-A B' any class of type A (Class A and all its subclasses) can reuse any implementation of type B (class B and all its subclasses). Has-A provides variable implementation reuse.
This is a very powerful thought, as you can build any hierarchy of Class A and reuse any implementation of type B in any implementation of type A without changing anything in the relationship.
So, in conclusion, Is-A should be used when type compatibility is the requirement. And, Has-A should generally be used for implementation reuse. Has-A must be used when reuse of variable implementation is required with or without type compatibility. The right use of both provides highly maintainable and extensible design.
Published at DZone with permission of Rohit Kumar. See the original article here.
Opinions expressed by DZone contributors are their own.