객체 지향 프로그래밍(OOP)을 사용하여 python 프로젝트를 진행할 때 다양한 클래스와 객체가 상호작용하여 특정 문제를 해결하는 방법을 계획하는 것(OOD)은 중요하다.
- 객체 지향 프로그래밍(OOP) ?
- Object-Oriented Programming
- 필요한 데이터를 추상화하여 속성, 행위를 가진 객체를 만들고, 그 객체들 간의 유기적인 상호 작용을 통해 로직을 구성하는 방식
- 객체 지향 설계 (OOD) ?
- 다양한 클래스와 객체가 상호작용하여 특정 문제를 해결하는 방법을 계획하는 것 (Class design)
- SOLID 원칙 참고 (5가지의 객체 지향 설계 원칙 세트)
목표
1. 각 SOLID 원칙의 의미와 목적을 이해한다.
2. SOLID 원칙 중 일부를 위반하는 python 코드를 식별한다.
3. SOLID 원칙을 적용하여 Python 코드를 리팩토링하고 디자인을 개선한다.
1. SOLID 원칙
- 객체지향 설계 (OOD)에 대해 가장 인기 있고 많이 알려진 표준적인 방식 중 하나는 SOLID 원칙이다.
- C++ 또는 JAVA 에서는 이미 이러한 원칙을 적용하고 있고, python 보다도 더 강제력이 있다. (강제력이 있다는 것은 SOLID 원칙대로 적용하지 않으면 코드 실행이 힘들 수 있다는 의미이다.)
- Single-responsibility principle (SRP) : 단일 책임 원칙
- Open–closed principle (OCP) : 개방형 - 폐쇄형 원리
- Liskov substitution principle (LSP) : Liskov 대체 원리
- Interface segregation principle (ISP) : 인터페이스 분리 원칙
- Dependency inversion principle (DIP) : 종속 역전원리
2. Single-responsibility principle (SRP)
A class should have only one reason to change.
- 클래스는 하나의 책임소재만 가지고 있어야한다.
- 클래스를 변경해야 하는 이유는 단 하나여야 한다.
- 클래스가 둘 이상의 작업을 처리하는 경우는 별도의 클래스로 분리해야한다.
2-1. 예제 코드 - bad case
# file_manager_srp.py
from pathlib import Path
from zipfile import ZipFile
class FileManager:
def __init__(self, filename):
self.path = Path(filename)
def read(self, encoding="utf-8"):
return self.path.read_text(encoding)
def write(self, data, encoding="utf-8"):
self.path.write_text(data, encoding)
def compress(self):
with ZipFile(self.path.with_suffix(".zip"), mode="w") as archive:
archive.write(self.path)
def decompress(self):
with ZipFile(self.path.with_suffix(".zip"), mode="r") as archive:
archive.extractall()
- 위 코드의 예에서 크게 파일을 관리하는 메서드(.read()와 .write()), ZIP아카이브 처리하는 메서드(.compress(), .decompress()) 두가지의 기능이 있다.
- 이 예시는 Single-responsibility principle를 위반하는 문제를 가지고 있기 때문에 더 작은 테스크 단위로 클래스를 나눌 필요가 있다.
- 한 클래스가 너무 커지게 되면 클래스의 의미가 모호해질 수 있다.
2-2. 예제 코드 - good case
# file_manager_srp.py
from pathlib import Path
from zipfile import ZipFile
class FileManager:
def __init__(self, filename):
self.path = Path(filename)
def read(self, encoding="utf-8"):
return self.path.read_text(encoding)
def write(self, data, encoding="utf-8"):
self.path.write_text(data, encoding)
class ZipFileManager:
def __init__(self, filename):
self.path = Path(filename)
def compress(self):
with ZipFile(self.path.with_suffix(".zip"), mode="w") as archive:
archive.write(self.path)
def decompress(self):
with ZipFile(self.path.with_suffix(".zip"), mode="r") as archive:
archive.extractall()
- 위의 좋은 예시 코드를 보면, 하나의 책임소재만 가진 두 개의 작은 클래스로 나눠진 것을 볼 수 있다. 이렇게 나눴을 경우 추론, 테스트, 디버깅을 진행할때 더 수월하게 작업할 수 있다.
- 책임소재에 대한 개념은 꽤 주관적일 수 있으나 이러한 주관성은 SRP 사용에 방해가 되어서는 안된다. (주관적으로 책임소재를 정의하되, SRP 원칙에 맞게 코드 작성이 되어야한다.)
3. Open–closed principle (OCP)
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
- extension(확장)은 가능해야하는데, modification(수정)은 불가능해야한다.
3-1. 예제 코드 - bad case
# shapes_ocp.py
from math import pi
class Shape:
def __init__(self, shape_type, **kwargs):
self.shape_type = shape_type
if self.shape_type == "rectangle":
self.width = kwargs["width"]
self.height = kwargs["height"]
elif self.shape_type == "circle":
self.radius = kwargs["radius"]
def calculate_area(self):
if self.shape_type == "rectangle":
return self.width * self.height
elif self.shape_type == "circle":
return pi * self.radius**2
from shapes_ocp import Shape
rectangle = Shape("rectangle", width=10, height=5)
rectangle.calculate_area()
circle = Shape("circle", radius=5)
circle.calculate_area()
- 위 예시 코드에서의 Shape 클래스를 통해 직사각형, 원형에 대한 면적을 계산할 수 있다. **kwargs를 통해서 특정 인수를 받아 클래스를 사용하도록 되어 있다. 만약 shape_type이 'rectangle'이라면 width와 height 인자를 받아와서 면적을 계산할 수 있다. 반대로, shape_type이 'circle' 이라면 radius 인자를 받아와서 면적을 계산할 수 있게 되어있다.
- 하지만, 새로운 모양(shape_type)을 정의하기 위해서는 Shape 클래스를 수정해줘야한다. (Ex, if self.shape_type == "triangle": ...)
- 이 방법은 Open–closed principle 에 위반되는 방식으로 extension(확장)은 가능하지만 modification(수정)은 불가능하도록 클래스를 수정해줘야한다.
3-2. 예제 코드 - good case
# shapes_ocp.py
from abc import ABC, abstractmethod
from math import pi
class Shape(ABC):
def __init__(self, shape_type):
self.shape_type = shape_type
@abstractmethod
def calculate_area(self):
pass
class Circle(Shape):
def __init__(self, radius):
super().__init__("circle")
self.radius = radius
def calculate_area(self):
return pi * self.radius**2
class Rectangle(Shape):
def __init__(self, width, height):
super().__init__("rectangle")
self.width = width
self.height = height
def calculate_area(self):
return self.width * self.height
class Square(Shape):
def __init__(self, side):
super().__init__("square")
self.side = side
def calculate_area(self):
return self.side**2
- 클래스를 완전히 리팩토링해서 추상 기본 클래스(ABC, abstract base class) 형태로 전환했다.
- 모든 모양에 따른 클래스가 정의 되었고, 모양에 필요한 인터페이스(API)를 제공한다. 해당 인터페이스는 모든 하위 클래스에서 재정의 하는 방식으로 구성되어야 한다. (Ex, .shape_type()속성과 .caclulate_area()속성)
- 추상 기본 클래스(ABC) 에서는 껍데기 형태로만 만들어도 무방하다. ABC는 다른 클래스가 상속받을 수 있는 템플릿으로 작동한다고 볼 수 있다. (Ex, class Shape(ABC): ... )
- 결론적으로, 위의 방식대로하면 클래스를 수정하지 않고도 클래스 템플릿에 새로운 모양(shape_type)을 추가할 수 있다. 기존의 코드를 수정하지 않기 때문에 협업에서 생길 수 있는 여러 이슈를 해결 할 수 있다.
4. Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types.
- 자식 클래스가 항상 부모 클래스의 역할을 충실히 수행하는 것
- 하위 클래스가 동일한 메서드르 호출할 때 상위 클래스 처럼 동작하도록 만들어야 한다.
4-1. 예제 코드 - bad case
# shapes_lsp.py
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def calculate_area(self):
return self.width * self.height
class Square(Rectangle):
def __init__(self, side):
super().__init__(side, side)
def __setattr__(self, key, value):
super().__setattr__(key, value)
if key in ("width", "height"):
self.__dict__["width"] = value
self.__dict__["height"] = value
>>> from shapes_lsp import Square
>>> square = Square(5)
>>> vars(square)
{'width': 5, 'height': 5}
>>> square.width = 7
>>> vars(square)
{'width': 7, 'height': 7}
>>> square.height = 9
>>> vars(square)
{'width': 9, 'height': 9}
- 위의 예제에서, Rectangle 클래스를 보면 .calculate_area() 메서드를 제공하고 있고, width와 height 인스턴스를 가지고 있다. Square 클래스에서는 정사각형이 변이 같은 직사각형의 형태이기 때문에 Rectangle의 하위 클래스로 정의한 것을 볼 수 있다.
- 그래서 Square 클래스 내부적으로 .__init__() 메서드를 사용하여 상위(Rectangle class) 속성에 있는 .width 및 .height 를 side 인수로 초기화한 것을 볼 수 있다. 또한 속성에 대한 새로운 값을 할당해주는 __setattr__ 메서드를 활용해서 width와 height 둘 다 side 값으로 설정해주었다.
- 하지만, Square(하위) 인스턴스는 Rectangle(상위) 인스턴스로 대체할 수 없기 때문에 Liskov Substitution Principle를 위반하게 된다. 이렇게 될 경우, 원하지 않는 결과를 얻을 수도 있고 디버깅이 어려울 수 있다는 단점이 있다.
- Liskov substitution principle를 준수하기 위해서는 Square과 Rectangle 인스턴스가 부모-자식 관계에 있어서는 안된다.
4-2. 예제 코드 - good case
# shapes_lsp.py
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def calculate_area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def calculate_area(self):
return self.width * self.height
class Square(Shape):
def __init__(self, side):
self.side = side
def calculate_area(self):
return self.side ** 2
- Shape 클래스 (부모 인스턴스)를 구성하면서 Rectangle과 Square가 부모-자식 관계가 아닌 Shape로 대체할 수 있는 유형이 된다.
- Rectangle과 Square 둘 다 고유한 속성을 가지고 있고, 서로 다른 초기화(__init__()) 방법을 가지며 잠재적으로 별도의 동작을 구현할 수 있다. 유일한 공통점은 면적을 계산( .calculate_area() )하는 것이다.
>>> from shapes_lsp import Rectangle, Square
>>> def get_total_area(shapes):
... return sum(shape.calculate_area() for shape in shapes)
>>> get_total_area([Rectangle(10, 5), Square(5)])
75
- 위의 코드에서는 직사각형과 정사각형으로 구성된 쌍으로 전체 면적을 구하는 함수를 구성해두었다.
- Rectangle( )과 Square( ) 공통적으로 .calculate_area( ) 에 집중되어? 있기 때문에 형태가 다른 것에 문제 없이 작동된다.
* SOLID 원칙을 잘 설명한 사이트(https://realpython.com/solid-principles-python/)를 참고하여 내용을 작성하였다.
반응형
'Language > python' 카테고리의 다른 글
[python] GitPython 라이브러리 (1) | 2024.10.13 |
---|---|
[python] Clean Code in Python 1. 코드 포매팅과 도구 (0) | 2024.05.01 |
[python] 'dict' object has no attribute 'to_csv' (0) | 2024.02.13 |
[이미지 처리] 알파채널 제거 (0) | 2024.02.06 |
[결측값 처리] fillna / backfill / bfill / pad / ffill (0) | 2024.02.05 |