Object-Oriented Design: Applying Cohesion and Coupling Principles to Create Well-Formed Objects
Cohesion and coupling are architectural concerns in the design of well-formed objects. (For an overview of object-oriented design, have a look at Object-Oriented Design Basics: Objects, Classes and Inheritance.)
Well-formed objects are cohesive and loosely coupled. Poor cohesion and tight coupling are design flaws, and are reasons to refactor a class architecture.
Cohesion
Cohesive objects do one thing, and avoid doing other things.
One of the indicators of a lack of cohesion is excessive decision logic. If an object has to put too much work into deciding what it is going to do when in use, then the object is addressing too many unrelated concerns.
Poor Cohesion Example
Here’s an example (in JavaScript) of an object that lacks sufficient cohesion:
class Shape {
constructor(shapeType, shapeData) {
this.shapeType = shapeType;
this.shapeData = shapeData;
}
area() {
switch (this.shapeType) {
case 'circle':
return Number((Math.PI * this.shapeData[0] ** 2).toFixed(2));
break;
case 'square':
return this.shapeData[0] ** 2;
break;
case 'rectangle':
return this.shapeData[0] * this.shapeData[1];
break;
default:
return "Don't recognize that shape";
}
}
}
const myCircle = new Shape('circle', [4]);
const mySquare = new Shape('square', [4]);
const myRect = new Shape('rectangle', [6, 3]);
for (let shape of [myCircle, mySquare, myRect]) {
console.log(shape.area());
}
// 50.27
// 16
// 18
The idea behind this approach is that because all shapes have an area, we can have a single area method to support all of them. The trouble with this is that the way to calculate the area varies a great deal from shape to shape. All shapes have an area, but that’s a bit of a red herring from a design perspective. This is because we can’t expose a single area method that holds good for all shapes. And the more shapes we add, the heavier the decision logic becomes, so it doesn’t scale well at all.
Cohesion problems often arise from situations like this, where certain related objects may do the same thing but do it differently. So, when applying the principle of cohesion, it is important to keep in mind not only what to do, but also how to do it. If different instances of an object do the same thing differently, it’s better to break the design into different classes.
How to Fix It
In the case of our Shape class, we’re better off breaking the class into separate classes, and implementing area in each of them in a manner appropriate to the particular shape:
class Circle {
constructor(radius) {
this.radius = radius;
}
area() {
return Number((Math.PI * this.radius ** 2).toFixed(2));
}
}
class Rectangle {
constructor(x, y) {
[this.x, this.y] = [x, y];
}
area() {
return this.x * this.y;
}
}
class Square extends Rectangle {
constructor(side) {
super(side, side);
}
}
const myCircle = new Circle(4);
const mySquare = new Square(4);
const myRect = new Rectangle(6, 3);
for (let shape of [myCircle, mySquare, myRect]) {
console.log(shape.area());
}
// 50.27
// 16
// 18
With this change, there is no longer a need to evaluate which type of shape we are dealing with when calculating an area. Consumers of the objects only need to instantiate the type of object they want, rather than telling the Shape object what type of object they want when they instantiate it. So, this design removes complexities as compared to the first design.
(Note: It makes sense to have Square inherit from Rectangle, because they use the same formula to calculate the area. A square just needs to ensure that the x and y values are the same, so we can pass the length of one side to the Square constructor, and send it to both x and y in the Rectangle constructor. But Circle uses a different formula to calculate its area, so it makes more sense to keep it entirely separate.)
Coupling
Objects with loose coupling minimize the amount of communication that they have to have with other objects to do what they do. Objects that are too tightly coupled know more than they need to about each other.
One of the indicators of overly tight coupling (often called overcoupling) between two objects is that you can’t change one without changing the other.
领英推荐
Too Tight Coupling Example
Here’s an example of an architecture with overly tight coupling:
class Food {
constructor(type) {
this.type = type;
}
eatWithFingers() {
new Utensil().operate(this);
}
eatWithFork() {
new Fork().operate(this);
}
eatWithSpoon() {
new Spoon().operate(this);
}
}
class Utensil {
constructor(action = 'eats', name = 'fingers') {
this.action = action;
this.name = name;
}
operate(food) {
console.log(`The hungry human ${this.action} the ${food.type} with the ${this.name}.`);
}
}
class Fork extends Utensil {
constructor() {
super('stabs', 'fork');
}
}
class Spoon extends Utensil {
constructor() {
super('scoops', 'spoon');
}
}
let pasta = new Food('spaghetti');
pasta.eatWithFork(); // The hungry human stabs the spaghetti with the fork.
let soup = new Food('soup');
soup.eatWithSpoon(); // The hungry human stabs the soup with the spoon.
let popcorn = new Food('popcorn');
popcorn.eatWithFingers(); // The hungry human eats the popcorn with the fingers.
In this design, the Food object has to call different methods for the different utensils that are used to eat it. This is a coupling problem, because every time you add a new utensil, you also have to add a new method to the Food object. For example, if you add a Chopsticks utensil, you’ll also have to add an eatWithChopsticks method to the Food object.
Furthermore, the utensil object has to know what kind of food it’s eating to be able to call its operate method. One object typically needs to know something about another to use it, but if that knowledge goes both ways, that’s another indicator of a coupling problem.
These two objects are too tightly coupled, then.
How to Fix It
The Food object should know as little as possible about what utensil it’s using to be eaten, and the different utensil objects don’t need to know anything at all about the type of food they are eating. We can make this happen by decoupling the Food object from the Utensil object and its subclasses.
We’ll start by replacing all the eatWith methods with a single eat method. We’ll let this method do the eating, instead of calling Utensil‘s operate method and passing the current Food instance to it.
To let the Food object know which utensil it’s using, we’ll store a reference to a Utensil object or one of its subclasses to a property of the Food object when we construct it. The eat method can use this to figure out which utensil to use.
Finally, we’ll get rid of the Utensil class‘s operate method, since we no longer need it.
Here’s the result:
class Food {
constructor(type, tool = Utensil) {
this.type = type;
this.tool = new tool();
}
eat() {
console.log(`The hungry human ${this.tool.action} the ${this.type} with the ${this.tool.name}.`);
}
}
class Utensil {
constructor(action = 'eats', name = 'fingers') {
this.action = action;
this.name = name;
}
}
class Fork extends Utensil {
constructor() {
super('stabs', 'fork');
}
}
class Spoon extends Utensil {
constructor() {
super('scoops', 'spoon');
}
}
class Chopsticks extends Utensil {
constructor() {
super('picks up', 'chopsticks')
}
}
let donut = new Food('donut');
donut.eat(); // The hungry human eats the donut with the fingers.
let pasta = new Food('spaghetti', Fork);
pasta.eat(); // The hungry human stabs the spaghetti with the fork.
let soup = new Food('soup', Spoon);
soup.eat(); // The hungry human scoops the soup with the spoon.
let sushi = new Food('sushi', Chopsticks);
sushi.eat(); // The hungry human picks up the sushi with the chopsticks.
This considerably simplifies the structure. The Food object no longer has to know about different ways it can be eaten, so it can focus on the concern of getting eaten. The instances no longer have to call different methods for different ways of eating food (eatWithFork, eatWithSpoon, eatWithChopsticks, etc.); they can all call the same eat method. Also, adding a Chopsticks class no longer requires any changes to the Food class.
Correlation Between Cohesion and Coupling
You may have noticed that the example of too-tight coupling also exhibits poor cohesion. Cohesion and coupling issues often go together, because both issues stem from insufficient separation of concerns.
In this example, the Food object has to do different things depending on which utensil to use while it’s being eaten. Not only does this require more communication between the Food and Utensil objects than necessary, it also strays from the Food object's purpose of being a food item that gets eaten, thereby violating the cohesion principle of sticking to one thing.
It’s important to keep cohesion and coupling in mind when designing a class architecture. Disjointed and/or tightly coupled classes are difficult to use and difficult to maintain. Cohesive, loosely coupled classes are easier to use, perform better, and are easier to maintain.