Solution to Challenge #2
First, let’s take a look at what is the Open/Closed principle in OOP.
Software entities should be open for extension but closed for modification.
Let’s refactor the code using the Open/Closed Principle by implementing an abstract base class and separate shape classes. This way, we can add new shapes by creating new classes without modifying existing code.
from abc import ABC, abstractmethod
from math import pi
from typing import Dict, Any
class Shape(ABC):
@abstractmethod
def calculate_area(self) -> float:
"""Calculate the area of the shape"""
pass
class Circle(Shape):
def __init__(self, radius: float):
if not isinstance(radius, (int, float)) or radius <= 0:
raise ValueError("Radius must be a positive number")
self.radius = radius
def calculate_area(self) -> float:
return pi * self.radius ** 2
class Rectangle(Shape):
def __init__(self, width: float, height: float):
if not isinstance(width, (int, float)) or width <= 0:
raise ValueError("Width must be a positive number")
if not isinstance(height, (int, float)) or height <= 0:
raise ValueError("Height must be a positive number")
self.width = width
self.height = height
def calculate_area(self) -> float:
return self.width * self.height
class ShapeFactory:
@staticmethod
def create_shape(shape_data: Dict[str, Any]) -> Shape:
"""Factory method to create shape objects from dictionary data"""
shape_type = shape_data.get('type', '').lower()
if shape_type == 'circle':
return Circle(shape_data['radius'])
elif shape_type == 'rectangle':
return Rectangle(shape_data['width'], shape_data['height'])
else:
raise ValueError(f"Unknown shape type: {shape_type}")
# Example usage:
if __name__ == "__main__":
# Using the shapes directly
circle = Circle(5)
rectangle = Rectangle(4, 6)
print(f"Circle area: {circle.calculate_area():.2f}")
print(f"Rectangle area: {rectangle.calculate_area():.2f}")
# Using the factory with dictionary data (similar to original interface)
shapes_data = [
{'type': 'circle', 'radius': 3},
{'type': 'rectangle', 'width': 5, 'height': 2}
]
for shape_data in shapes_data:
shape = ShapeFactory.create_shape(shape_data)
print(f"{shape_data['type'].capitalize()} area: {shape.calculate_area():.2f}")
# To add a new shape (e.g., Triangle), we can just create a new class:
class Triangle(Shape):
def __init__(self, base: float, height: float):
if not isinstance(base, (int, float)) or base <= 0:
raise ValueError("Base must be a positive number")
if not isinstance(height, (int, float)) or height <= 0:
raise ValueError("Height must be a positive number")
self.base = base
self.height = height
def calculate_area(self) -> float:
return 0.5 * self.base * self.height
# Update factory to handle the new shape
def create_shape_updated(shape_data: Dict[str, Any]) -> Shape:
"""Updated factory method including triangle support"""
shape_type = shape_data.get('type', '').lower()
if shape_type == 'circle':
return Circle(shape_data['radius'])
elif shape_type == 'rectangle':
return Rectangle(shape_data['width'], shape_data['height'])
elif shape_type == 'triangle':
return Triangle(shape_data['base'], shape_data['height'])
else:
raise ValueError(f"Unknown shape type: {shape_type}")
# Example using the new shape:
triangle = Triangle(4, 3)
print(f"Triangle area: {triangle.calculate_area():.2f}")This refactored version offers several advantages:
Open/Closed Principle
The code is open for extension (new shapes can be added)
Closed for modification (existing code doesn't need to change)
Single Responsibility Principle
Each shape class handles only its own area calculation
The factory handles object creation
Error Handling
Input validation for each shape's dimensions
Clear error messages for invalid inputs
Type Safety
Abstract base class ensures all shapes implement calculate_area
Type hints improve code clarity and IDE support
To add a new shape:
Create a new class inheriting from Shape
Implement the calculate_area method
Add the shape to the factory method (or create a new factory)
The original interface can still be used through the ShapeFactory, but now the code is more maintainable and extensible.
This begs the question. We can simply add more logic to the original code right? In the new implementation, we are just adding it in a different place. How's that more maintainable?
Let’s go through the benefits of the refactored approach from the context of this question.
Isolation of changes
In the original approach, if we add new logic to calculate area of a shape,
# Original approach
class Shape:
def calculate_area(self, shape):
if shape['type'] == 'circle':
return 3.14 * shape['radius'] ** 2
elif shape['type'] == 'rectangle':
return shape['width'] * shape['height']
elif shape['type'] == 'triangle': # Adding new shape
return 0.5 * shape['base'] * shape['height']
# Every new shape requires modifying this methodAny new shape will require changes to the calculate_area method.
Under the new approach,
# New approach
class Circle(Shape):
def calculate_area(self):
return pi * self.radius ** 2
class Rectangle(Shape):
def calculate_area(self):
return self.width * self.height
# New code in new file
class Triangle(Shape): # Adding new shape
def calculate_area(self):
return 0.5 * self.base * self.heightThe existing code remains untouched.
Risk of bugs
Let’s say we accidentally induce a bug while adding new logic,
# Original approach - modifying existing code
def calculate_area(self, shape):
if shape['type'] == 'circle':
return 3.14 * shape['radius'] ** 2
elif shape['type'] == 'rectangle':
return shape['width'] * shape['height']
elif shape['type'] == 'triangle':
# Bug: Accidentally modified existing logic while adding new code
return 0.5 * shape['base'] * shape['width'] # Used width instead of height!
# Bug affects all shapes because they share the same methodUnder the new approach, the potential bug will affect new code only.
# New approach - isolated changes
class Triangle(Shape):
def calculate_area(self):
return 0.5 * self.base * self.height
# Bug only affects new code, existing shapes work perfectlyEase of testing
Testing is more focussed on the new changes only.
# Original approach - need to retest everything
def test_shape_areas():
shape = Shape()
# Need to rerun ALL these tests after any change
assert shape.calculate_area({'type': 'circle', 'radius': 2}) == 12.56
assert shape.calculate_area({'type': 'rectangle', 'width': 2, 'height': 3}) == 6
assert shape.calculate_area({'type': 'triangle', 'base': 2, 'height': 3}) == 3# New approach - test only what changed
def test_circle():
circle = Circle(2)
assert circle.calculate_area() == 12.56
def test_rectangle():
rectangle = Rectangle(2, 3)
assert rectangle.calculate_area() == 6
def test_triangle(): # Only need to test new code
triangle = Triangle(2, 3)
assert triangle.calculate_area() == 3Type safety
Any modern IDE can catch these problems before it is too late. For instance, this typo in the old approach looks just fine, until it isn’t.
# Original approach
shape = {'type': 'circle', 'radius': 2}
# No IDE autocomplete for shape properties
# Easy to make typos in dictionary keys
shape = {'type': 'circle', 'radus': 2} # Typo in 'radius'Whereas, in the new approach,
# New approach
circle = Circle(2)
circle.radius # IDE autocomplete works
# Type checking catches errors
circle.radius # IDE shows error immediatelyThe key benefits are:
Each shape's logic is isolated and self-contained
Adding new shapes doesn't risk breaking existing code
Testing is more focused and maintainable
Better IDE support and type safety
Clearer documentation and code organization
Easier to distribute work among team members
Simpler to understand each shape's implementation
Remember, we spend more time reading code than writing it!


Interesting sir