CS247 Lecture 17

Last Time: Casting, coupling/cohesion, S in SOLID

This Time: SOL in SOLID

Should main be performing communication? No - limits reusability.

Another possibility: Use the MVC architecture well-suited for SRP.

MVC: Model-View-Controller

  • Model: Handles logic + data of your program.
  • View: Handles program output / display
  • Controller: Handles input, facilitates control flow between classes

Model: May have multiple views, or just one - different types of views: text, graphical view, etc. Very good to use it in a chess engine for the text and graphic display, something similar to the Observer pattern which is a good place to use it for.

  • Model doesn’t need to know anything about the state of the views and implementation of the views.
  • Picture the Observer pattern Model as the Subject and View being the Observer.
    • Sometimes we structure interactions between Model and View via an Observer pattern.

Controller: Mediates control flow.

  • May encapsulate things like turn-taking or some portion of the game rules.
  • Some of this logic may go in the Model instead: judgement call.
  • May communicate with user for input(sometimes done by the View).

By decoupling presentation, input, and data / logic we follow SRP, and promote re-use in our programs.

On the other hand, there maybe parts of this specifications that are unlikely to change, and so adding layers of abstraction just to follow SRP may not be worthwhile.

Note: Avoiding needless complexity is also worthwhile your best judgement.


Open / Closed Principle

Classes should be open to extension, but closed to modification.

Changes to a program should occur primarily by writing new code, not changing code that already exists.

Example: Hero has Sword.

  • What if we wanted our Hero to use a different type of weapon? Bow or magic wand?
  • Requires us changing all the places we referenced the sword class - lots of modification, and not much extension.

Fix: Hero has an abstract Weapon Class, deriving from abstract weapon, we have concrete Sword, Bow, Want. And we can add as much weapons as we want without changing the weapon class.

Strategy Pattern

Open / closed principle: Closely related to the concept of inheritance and virtual functions.

For example:

int countHeavy(const vector<Book*>& v){
	int count = 0;
	for (auto p: v){
		if(p->isHeavy()) ++count;
	}
	return count;
}

This function is open to extension: we can add more functionality by defining new types of books, and closed to modification. Since isHeavy() is overloaded.

Compare this to WhatisIt: Not open to extension: Cannot get new behaviour without modifying the source code not closed to modification either.

Open/closed principle is an ideal = writing a program will require modifications at some point.

Plan for things that are most likely to change, account for those.

High Cohesion vs. SRP

High Cohesion: High cohesion refers to the degree to which the responsibilities and functionalities of a software component are related and focused. In simpler terms, it means that a component should have a single, well-defined purpose and should perform tasks that are closely related to that purpose. 

Single Responsibility Principle (SRP): The Single Responsibility Principle is one of the SOLID principles of object-oriented design. It states that a class should have only one reason to change, or in other words, a class should have only one responsibility. This principle aligns closely with the idea of high cohesion but is applied at the class or module level. Each class should have a single, well-defined responsibility and should encapsulate only one aspect of functionality.


Liskov Substitution Principle

Enforces something discussed so far strictly:

  • public inheritance should model an is-a relationship.

If class B inherits from class A: we can use pointers / references to B objects in the place of pointers / references to A objects: C++ gives us this.

Liskov substitution is stronger: not only can we perform the substitution, but we should be able to do so at any place in the program, without affecting its correctness.

So precisely:

  • If an invariant is true for class A, then it should also be true for class B.
  • If an invariant is true for a method A::f, and B overrides f, then this invariant must be true for B::f.
  • If B::f overrides A::f:
    • If A::f has a precondition P and a post condition Q, then B::f should have a precondition P’ such that PP’ and a post condition Q’ such that Q’ Q. B::f needs a weaker precondition and a stronger postcondition.

Example: Contra-variance problem https://piazza.com/class/lh4qjengjtl5zy/post/736

  • Happens wherever we have a binary operator where the other parameter is the same type as *this.
class Shape {
	public:
		virtual bool operator==(const Shape& other) const;
};
 
class Circle: public Shape{
	public: 
		bool operator==(const Shape& other) const override;
};

As we’ve seen before, we must take in the same parameter when overriding. C++ enforces this, which actually enforces LSP (Liskov Substitution Principle) for us.

  1. A circle is-a shape
  2. A shape can be compare with other Shapes.
  3. A circle can be compared with any other Shape.

To satisfy LSP, we must support comparison between different types of shapes.

bool circle::operator==(const Shape& other) const{
	if(typeid(other) != typeid(circle)) return false;
 
	const Circle& cother=static_cast<const Circle&> (other;)
	...
}

We already performed typeid on circle, so we know that it is a circle, don’t need dynamic_cast so we use static_cast

typeid returns std::type_info objects that can be compared.

dynamic_cast: tells us Is other a Circle or a subclass of Circle? typeid: Is other exactly a Circle.

typeid uses the dynamic-type so long as the class has at least one virtual method.

Ex: Is a Square a Rectangle? 4th grade will tell us yes, a square is a rectangle.

A square has all the properties of a rectangle.

class Rectangle {
	int length, width;
	public:
		Rectangle(int&, int w): length{l}, width{w}{}
		int getLength() const;
		int getWidth() const;
		virtual void setWidth(int w){witdth = w;}
		virtual void setLength(int l) {length = l;}
		int area() const {return length * width;}
};
 
class Square: public Rectangle {
	public:
		Square(int s): Rectangel{s, s}{}
		void setWidth(int w) override{
			Rectangle::setWidth(w);
			Rectangle::setLength(w)
		}
		void setLength(int l) override{
			Rectangle::setWidth(l);
			Rectangle::setLength(l);
		}	
};
 
int f(Rectangle& r){
	r.setLength(10);
	r.setWidth(20);
	return r.area(); // Surely this whould return 200
}

But:

Square s{100};
f(s); // Gives us 400

Issue: we expect our postcondition Rectangle setWidth to be that the width is set and nothing else changes. Violated by our Square class. We basically violate the postcondition of the Rectangle::setWidth method, which specifies that only the width should change.

Violates LSP, does not satisfy an is-a relationship. Conclusion: Rectangle is not a square. Squares are not Rectangles.

Summary

The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program. In other words, if you have a derived class, it should behave in a way that is consistent with the base class.

In the example, we have a base class Rectangle and a derived class Square. Correct to say that all squares are rectangles. However, problem arises when you try to model this relationship using inheritance and override the methods in the derived class.

Main issue is violation of the LSP. The overridden setWidth and setLength methods in the Square class modify both the width and the length, which is not consistent with the behaviour expected from a Rectangle. Leads to unexpected behaviour when you pass a Square object to the f function as observed.

In the LSP-compliant code, a Square should not inherit directly from Rectangle, because the specific behaviour of Square’s setters conflicts with the behaviour expected from Rectangle. One way to design this relationship correctly would be to create an abstract base class that defines the interface for a quadrilateral and then have separate classes for Rectangle and Square that adhere to their respective behaviours: (code provided below)

class Quadrilateral {
	protected:
	    int length, width;
 
public:
    Quadrilateral(int l, int w) : length{l}, width{w} {}
    virtual int area() const = 0;
    virtual ~Quadrilateral() = default;
};
 
class Rectangle : public Quadrilateral {
	public:
	    Rectangle(int l, int w) : Quadrilateral(l, w) {}
	    int area() const override { return length * width; }
};
 
class Square : public Quadrilateral {
	public:
	    Square(int s) : Quadrilateral(s, s) {}
	    int area() const override { return length * length; }
};

Note

By separating the behavior of Rectangle and Square into their own classes and avoiding the problematic overrides, you ensure that you’re adhering to the Liskov Substitution Principle. This design also makes it clear that a Square is not a special case of a Rectangle in terms of behavior, and the Quadrilateral base class captures the common interface for both shapes.

Possibility: Restructure inheritance hierarchy to make sure LSP is respected. (basically same thing as in the code above)

In general: How can we prevent LSP violations?

Restrain what our subclasses can do, only allow them to customize what is truly necessary.

We can use a design pattern: Template method pattern or Visitor pattern https://piazza.com/class/lh4qjengjtl5zy/post/736. We’ll provide a “structure” or “recipe” or “template” for what this method will do, allow subclasses to customize portions of this method by performing overrides.

General: When a subclass should be able to modify some parts of a method’s behaviour, but not all. (All Template Method Pattern is about)

Next: CS247 Lecture 18