Witajcie! Dzisiaj chciałbym podzielić się z Wami kilkoma fundamentalnymi zasadami projektowania oprogramowania, które omówiliśmy na jednym ze spotkań społeczności PyMasters (są miejsca!). Znając cechy dobrego projektu oraz wykorzystując zasady takie jak "Hermetyzuj to, co się różni" oraz "Programuj pod interfejs, nie pod implementację", możemy tworzyć bardziej zrozumiałe, elastyczne i łatwe w utrzymaniu aplikacje. Zapraszam do lektury!
Cechy dobrego projektu
Dobry projekt oprogramowania charakteryzuje się kilkoma kluczowymi cechami, które zapewniają jego długowieczność i łatwość w utrzymaniu.
Ponowne użycie kodu
Ponowne użycie kodu to praktyka, która pozwala na efektywne wykorzystanie istniejących komponentów w różnych częściach aplikacji, redukując tym samym kopiowanie kodu i zwiększając spójność.
Przykład:
Stworzenie modułu logowania, który może być używany w różnych aplikacjach.
class Logger:
def log(self, message):
print(f"Log: {message}")
class FileProcessor:
def __init__(self, logger: Logger):
self.logger = logger
def process(self, file_path):
self.logger.log(f"Processing file: {file_path}")
# logika przetwarzania pliku
logger = Logger()
file_processor = FileProcessor(logger)
file_processor.process("/path/to/file")
Rozszerzalność
Rozszerzalność oznacza zdolność systemu do łatwego dodawania nowych funkcjonalności bez konieczności zmiany istniejącego kodu.
Przykład:
Do naszego systemu logowania możemy łatwo dodać opcje logowania do pliku, zamiast na ekran.
from datetime import date
class FileLogger:
def log(self, message):
with open("log.txt", "a") as file:
current_date = date.today()
file.write(f"Log ({current_date}): {message}\n")
logger = FileLogger()
file_processor = FileProcessor(logger)
file_processor.process("/path/to/file")
W efekcie mamy utworzony plik log.txt
w którym są zapisane wiadomości z przetwarzania pliku. Jako bonus, jest dodana data wpisu co pozwala na łatwe znalezienie historycznych wiadomości. Warto zwrócić uwagę na to, że FileProcessor
nie ma pojęcia o zmianach.
Testowalność
Testowalność oznacza zdolność do łatwego testowania poszczególnych komponentów systemu. Jest to kluczowe dla utrzymania jakości oprogramowania. Osobiście jestem wielkim fanem testowania i programowania sterowanego testami - Test Driven Development. Korzystanie z tych praktyk bardzo ułatwia życie zwłaszcza przy dużych projektach.
Przykład:
Stosowanie dependency injection (wstrzykiwania zależności), aby ułatwić mockowanie (podstawianie) w testach.
class EmailService:
def send_email(self, recipient, message):
# logika wysyłania emaila
pass
class UserRegistration:
def __init__(self, email_service: EmailService):
self.email_service = email_service
def register(self, user_info):
# logika rejestracji użytkownika
self.email_service.send_email(user_info.email, "Welcome!")
class MockEmailService:
def send_email(self, recipient, message):
print(f"Mock email sent to {recipient} with message: {message}")
mock_email_service = MockEmailService()
registration = UserRegistration(mock_email_service)
registration.register(user_info)
Ten przykład pokazuje jak podstawienie MockEmailService
umożliwia testy UserRegistration
. Używając klasy EmailService
zapewne musielibyśmy wysłać prawdziwy email, czyli mieć skonfigurowane konto pocztowe itp. Dużo pracy.
MockEmailSerice
z kolei nadpisuje metode sent_email
, co pozwala nam całkowicie pominąć wysyłke maila - tylko sprawdzamy czy metoda została uruchomiona, tj. czy była próba wysłania maila. W końcu testujemy tylko UserRegistration
. Testowanie klasy EmailService
nie jest tutaj celem.
Zasady projektowania oprogramowania
Cechy dobrego projektu to nie wszystko. Właściwie, po tym można ocenić wynik. Jakimi zasadami należy się kierować przy projektowaniu oprogramowania? O tym poniżej.
Hermetyzuj to, co się różni
Zasada:
Identyfikuj aspekty aplikacji, które ulegają zmianom i oddziel je od tych, które są stabilne. Pomaga to w tworzeniu oprogramowania łatwiejszego do zrozumienia, testowania i utrzymania.
Przykład:
Przykładem może być system zarządzania pracownikami, gdzie zamiast bezpośrednio manipulować danymi w bazie, oddzielamy logikę zapisu i usuwania danych.
class EmployeeStorage:
def save_employee(self, employee_data):
# implementacja szczegółów zapisu do bazy danych
pass
def delete_employee(self, employee_id):
# implementacja szczegółów usuwania z bazy danych
pass
class MySQLEmployeeStorage(EmployeeStorage):
def save_employee(self, employee_data):
print("Zapis do bazy danych MySQL")
def delete_employee(self, employee_id):
print("Usuwanie z bazy danych MySQL")
class EmployeeManager:
def __init__(self, storage: EmployeeStorage):
self.storage = storage
def add_employee(self, employee_data):
self.storage.save_employee(employee_data)
def remove_employee(self, employee_id):
self.storage.delete_employee(employee_id)
manager = EmployeeManager(MySQLEmployeeStorage())
Co to daje? Kilka zalet:
- Modularność: Oddzielenie logiki zapisu i usuwania danych od logiki biznesowej ułatwia wprowadzanie zmian bez wpływu na inne części systemu.
- Testowalność: Możliwość testowania klasy EmployeeManager niezależnie od konkretnej implementacji EmployeeStorage.
- Elastyczność: Możliwość łatwego podmienia implementacji EmployeeStorage (np. na inną bazę danych) bez zmiany logiki biznesowej.
Programuj pod interfejs, nie pod implementację
Zasada:
Opieraj się na abstrakcjach zamiast na konkretnych klasach. Promuje to elastyczność i łatwość w utrzymaniu kodu.
Przykład:
Zamiast bezpośrednio korzystać z konkretnej klasy raportów, używamy abstrakcyjnego interfejsu, który może mieć różne implementacje.
from abc import ABC, abstractmethod
class Report(ABC):
@abstractmethod
def generate(self, content):
pass
class PDFReport(Report):
def generate(self, content):
# Implementacja generowania raportu PDF
pass
class HTMLReport(Report):
def generate(self, content):
# Implementacja generowania raportu HTML
pass
class ReportGenerator:
def generate_report(self, content, report: Report):
report.generate(content)
html_report = HTMLReport()
pdf_report = PDFReport()
generator = ReportGenerator()
generator.generate_report("Treść raportu", html_report)
generator.generate_report("Treść raportu", pdf_report)
Zalety:
- Elastyczność: Łatwo można dodać nowe typy raportów bez modyfikacji istniejącego kodu. Po prostu dodajemy nową klasę.
- Testowalność: Łatwo można testować każdy typ raportu niezależnie od pozostałych. Dzięki parametrowi "content", czyli treści do wygenerowania raportu, łatwo można podstawić dane testowe, co z kolei uniezależnia test od pozostałych części aplikacji.
Preferuj kompozycję ponad dziedziczenie
Zasada:
Zamiast dziedziczyć po klasach bazowych, lepiej jest komponować obiekty z mniejszych, wyspecjalizowanych komponentów. Zmniejsza to ryzyko wprowadzania niezamierzonych błędów i ułatwia rozszerzanie aplikacji o nowe funkcjonalności.
Przykład:
Zamiast tworzyć głęboką hierarchię dziedziczenia dla różnych typów postaci, używamy kompozycji.
class FlyBehavior:
def fly(self):
print("This character can fly.")
class SwimBehavior:
def swim(self):
print("This character can swim.")
class Character:
def __init__(self):
self.abilities = {} # Słownik na umiejętności
def add_ability(self, ability_name, ability):
self.abilities[ability_name] = ability
def perform_ability(self, ability_name):
ability = self.abilities.get(ability_name)
if ability:
method = getattr(ability, ability_name, None)
if method:
method()
else:
print(f"No such ability: {ability_name}")
else:
print(f"Ability {ability_name} not found.")
swim_character = Character()
swim_character.add_ability("swim", SwimBehavior())
fly_character = Character()
fly_character.add_ability("fly", FlyBehavior())
fly_character.perform_ability("fly") # Wywoła FlyBehavior.fly()
fly_character.perform_ability("swim") # No such ability: swim
swim_character.perform_ability("swim") # Wywoła SwimBehavior.swim()
Superman = Character()
# Superman potrafi wszystko
Superman.add_ability("swim", SwimBehavior())
Superman.add_ability("fly", FlyBehavior())
Superman.perform_ability("fly") # Wywoła FlyBehavior.fly()
Superman.perform_ability("swim") # Wywoła SwimBehavior.swim()
Zalety:
Modularność: Łatwo można dodawać lub zmieniać umiejętności postaci bez modyfikacji jej klasy.
Elastyczność: Możliwość dynamicznego dodawania nowych umiejętności w trakcie działania programu.
Testowalność: Każdą umiejętność można testować niezależnie, co zwiększa czytelność i niezawodność testów.
Podsumowanie
Znajomość i stosowanie tych zasad projektowania oprogramowania może znacznie podnieść jakość Waszego kodu. Jeżeli macie ochotę na więcej takich praktycznych wskazówek i chcecie rozwijać się w grupie pełnej pasjonatów Pythona, dołączcie do naszej społeczności PyMasters. Cotygodniowe spotkania edukacyjne są doskonałą okazją do nauki i wymiany doświadczeń. Zapraszamy serdecznie!
Zapraszam do zadawania pytań przez formularz kontaktowy. Pamiętaj, że jeśli potrzebujesz wsparcia, możesz napisać do mnie - pomogę.
Spodobał Ci się post?
Podziel się nim!
Masz uwagi do posta, chcesz porozmawiać, szukasz pomocy z Pythonem i Django? Napisz do mnie!