Принципи на качествения код в Python#
Съдържание:
SOLID
Clean Code
PEP8
Pylint
Пример
Писането на код е само една част от това да си програмист. В живота на едно парче код, то по-често ще бъде прочитано, отколкото променяно. В нашият програмистки живот, по-често ще ни се налага да четем код, отколкото да пишем. Затова едно от ключовите ни умения като програмист е това да пишем качествен и четим код.
Съществуват някои широко-разпространени добри практики за писане на качествен код (а и архитектура). Ще разгледаме някои тях
SOLID#
SOLID е колекция от принципи, имащи за цел да направят нашия (обектно-ориентиран) код по-четим, по-лесен за поддържане и по-гъвква. SOLID е акроним на 5 принципа, които ще разгледаме по-долу - Single-reponsibility principle, Open-closed principle, Liskov substitution principle, Interface segregation principle и Dependency inversion principle.
Single-reponsibility#
Както името му подсказва, single-reponsibility принципа казва, че “един клас не трябва да има повече от една причина да се променя”. На по-прост език, това означава, че един клас трябва да има една отговорност/дейност.
Тази идея може да бъде разширена и към всички други програмни единици, не само класовете. Трябва да се стараем да пишем функции или класове, които правят едно нещо.
Нека разгледаме следната задача (source: Advent of Code 2022, day 18, pt1):
Получаваме списък от координати на кубчета в тримерното пространство. На изхода на програмата трябва да върнем броя на “видимите” страни на кубчетата - например, ако имаме едно кубче с координати (1, 1, 1), и второ кубче с координати (2, 1, 1), то броя на видимите страни е 10 (две от страните няма да се виждат, защото са долепени една до друга).
Как бихме написали кода за тази задача ?
def solution():
line = input()
lines = []
while line != '':
lines.append(line)
line = input()
cubes = []
for line in lines:
x, y, z = line.split(',')
cubes.append((int(x), int(y), int(z)))
sides = 0
for source_cube in cubes:
hidden_sides = 0
for target_cube in cubes:
diff_x = abs(source_cube[0] - target_cube[0])
diff_y = abs(source_cube[1] - target_cube[1])
diff_z = abs(source_cube[2] - target_cube[2])
if (diff_x == 1 and diff_y == 0 and diff_z == 0) or (diff_x == 0 and diff_y == 1 and diff_z == 0) or (diff_x == 0 and diff_y == 0 and diff_z == 1):
hidden_sides += 1
sides += 6 - hidden_sides
print(sides)
solution()
10
Очевидно горната функция solution
прави много неща - какво правим, ако имаме бъг в решението ? Трябва да прегледаме целия код, което тук е само 30 реда, но представете си, че говорим за 300 реда. Също така, ако направим промяна, не е много сигурно, че няма да счупим някое друго парче код. Затова нека започнем прилагането на принципа за единстветана отговорност.
Първото нещо, което ни хрумва да направим, е да отделим входа, сметките и изхода в отделни функции.
def handle_input() -> list[tuple[int, int, int]]:
line = input()
lines = []
while line != '':
lines.append(line)
line = input()
cubes = []
for line in lines:
x, y, z = line.split(',')
cubes.append((int(x), int(y), int(z)))
return cubes
def solution(cubes: list[tuple[int, int, int]]):
sides = 0
for source_cube in cubes:
hidden_sides = 0
for target_cube in cubes:
diff_x = abs(source_cube[0] - target_cube[0])
diff_y = abs(source_cube[1] - target_cube[1])
diff_z = abs(source_cube[2] - target_cube[2])
if (diff_x == 1 and diff_y == 0 and diff_z == 0) or (diff_x == 0 and diff_y == 1 and diff_z == 0) or (diff_x == 0 and diff_y == 0 and diff_z == 1):
hidden_sides += 1
sides += 6 - hidden_sides
return sides
def handle_output(sides: int):
print(f'The answer is {sides}')
cubes = handle_input()
result = solution(cubes)
handle_output(result)
The answer is 10
Една идея по-добре. Но все още нашите функции handle_input
и solution
правят повече от едно неща.
Нека разгледаме в детайли handle_input
- макар и кратка, тя прави две основни неща - приема входа от клавиатурата, и го конвертира от низ до наредена тройка от цели числа. Какво би станало, ако искаме вместо от клавиатурата, нашия вход да се чете от файл ? Или пък от графичен интерфейс ? Тази промяна не би трябвало да има общо с това как превръщаме низ към наредена тройка. Затова ще разделим handle_input
на две по-малки функции.
def read_input() -> list[str]:
line = input()
lines = []
while line != '':
lines.append(line)
line = input()
return lines
def transform_input(lines: list[str]) -> list[tuple[int, int, int]]:
# [f(x) for x in X]
cubes = []
for line in lines:
x, y, z = line.split(',')
cubes.append((int(x), int(y), int(z)))
return cubes
def solution(cubes: list[tuple[int, int, int]]):
sides = 0
for source_cube in cubes:
hidden_sides = 0
for target_cube in cubes:
diff_x = abs(source_cube[0] - target_cube[0])
diff_y = abs(source_cube[1] - target_cube[1])
diff_z = abs(source_cube[2] - target_cube[2])
if (diff_x == 1 and diff_y == 0 and diff_z == 0) or (diff_x == 0 and diff_y == 1 and diff_z == 0) or (diff_x == 0 and diff_y == 0 and diff_z == 1):
hidden_sides += 1
sides += 6 - hidden_sides
return sides
def handle_output(sides: int):
print(f'The answer is {sides}')
lines = read_input()
cubes = transform_input(lines)
result = solution(cubes)
handle_output(result)
Така вече, ако се наложи промяна в начина по който четем входа, няма да се налага да променяме функцията, която държи и логиката за трансформирането на входа. Или ако пък се промени формата на входа, трябва да променим само transform_input
, без да пипаме read_input
.
Друг плюс от това разделение е, че функцията read_input
вече не е обвързана по никакъв начин с конкретната задача - спокойно тя може да бъде преизползвана за решаването на други задачи, които изискват четене от клавиатурата.
Можем обаче да отидем една стъпка по-напред - нека отделим логиката за трансформиране на само един ред, отделно.
def read_input() -> list[str]:
line = input()
lines = []
while line != '':
lines.append(line)
line = input()
return lines
def line_to_tuple(line: str) -> tuple[int, int, int]:
x, y, z = line.split(',')
return int(x), int(y), int(z)
def transform_input(lines: list[str]) -> list[tuple[int, int, int]]:
return [line_to_tuple(line) for line in lines]
def solution(cubes: list[tuple[int, int, int]]):
sides = 0
for source_cube in cubes:
hidden_sides = 0
for target_cube in cubes:
diff_x = abs(source_cube[0] - target_cube[0])
diff_y = abs(source_cube[1] - target_cube[1])
diff_z = abs(source_cube[2] - target_cube[2])
if (diff_x == 1 and diff_y == 0 and diff_z == 0) or (diff_x == 0 and diff_y == 1 and diff_z == 0) or (diff_x == 0 and diff_y == 0 and diff_z == 1):
hidden_sides += 1
sides += 6 - hidden_sides
return sides
def handle_output(sides: int):
print(f'The answer is {sides}')
lines = read_input()
cubes = transform_input(lines)
result = solution(cubes)
handle_output(result)
The answer is 10
Тук задаваме въпросите - имаме ли нужда да раздробяваме кода чак толкова и имаме ли нужда от функция, която е само един ред ?
Отговора на двата въпроса е един и същ - зависи. Всичко опира до конкретната задача, и конкретния стил на човека - някои казват, че ако функцията е един ред, няма нужда от нея. Но пък за сметка на това, transform_input
е по-лесно четимо от [line_to_tuple(line) for line in lines]
.
Нека сега приложим същите идеи и върху solution
.
def read_input() -> list[str]:
line = input()
lines = []
while line != '':
lines.append(line)
line = input()
return lines
def line_to_tuple(line: str) -> tuple[int, int, int]:
x, y, z = line.split(',')
return int(x), int(y), int(z)
def transform_input(lines: list[str]) -> list[tuple[int, int, int]]:
return [line_to_tuple(line) for line in lines]
def is_side_hidden(first_cube: tuple[int, int, int], second_cube: tuple[int, int, int]) -> bool:
# diffs = [abs(first_cube[direction] - second_cube[direction]) for direction in range(3)]
# return sum(diffs) == 1
diff_x = abs(first_cube[0] - second_cube[0])
diff_y = abs(first_cube[1] - second_cube[1])
diff_z = abs(first_cube[2] - second_cube[2])
is_side_x_hidden = (diff_x == 1 and diff_y == 0 and diff_z == 0)
is_side_y_hidden = (diff_x == 0 and diff_y == 1 and diff_z == 0)
is_side_z_hidden = (diff_x == 0 and diff_y == 0 and diff_z == 1)
return is_side_x_hidden or is_side_y_hidden or is_side_z_hidden
def count_visible_sides(cube: tuple[int, int, int], others: list[tuple[int, int, int]]) -> int:
return 6 - sum(1 for other in others if is_side_hidden(cube, other))
def solution(cubes: list[tuple[int, int, int]]):
visible_sides = [count_visible_sides(cube, cubes) for cube in cubes]
return sum(visible_sides)
def handle_output(sides: int):
print(f'The answer is {sides}')
lines = read_input()
cubes = transform_input(lines)
result = solution(cubes)
handle_output(result)
Point(x=2, y=3, z=4)
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[1], line 8
5 p1 = Point(2, 3, 4)
6 print(p1)
----> 8 def read_input() -> list[str]:
9 line = input()
10 lines = []
TypeError: 'type' object is not subscriptable
Спрямо първоначалното ни решение (което бе 29 реда), това вече е 42 реда - но е доста по-четимо, доста по-лесно за промяна и доста по-тестваемо. is_side_hidden
може да бъде написана по-кратко, но по-четимия код е по-добър от по-краткия.
Open-closed#
Open-closed принципа гласи, че софтуерните компоненти трябва да са отворени за разширение, но затворени за модификация.
Нека разгледаме примера, където имаме клас Rectangle
, и функция която пресмята сумата от лицата на всички правоъгълници в списък.
from typing import List
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def sum_areas(rectangles: List[Rectangle]):
return sum(rect.width * rect.height for rect in rectangles)
rectangles = [Rectangle(1, 2), Rectangle(3, 4)]
print(sum_areas(rectangles))
14
Ако добавим нов клас Square
, трябва да променим функцията, за да може да работи с новия клас.
from typing import List, Union
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
class Square:
def __init__(self, width):
self.width = width
def sum_areas(items: Union[List[Rectangle], List[Square]]):
if isinstance(items[0], Square):
return sum(square.width * square.width for square in items)
elif isinstance(items[0], Rectangle):
return sum(rect.width * rect.height for rect in items)
else:
return 0
Правилният подход тук е да създадем нов клас Shape
, който да има метод area
, и да променим функцията да работи с обекти от тип Shape
.
from abc import ABC
from typing import List, Union
class Shape(ABC):
def area(self) -> int:
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Square(Shape):
def __init__(self, width):
self.width = width
def area(self):
return self.width * self.width
def sum_areas(items: List[Shape]):
return sum(item.area() for item in items)
Така вече, sum_areas
е отворена за разширение (чрез добавяне на нови класове, които наследяват Shape
), но затворена за модификация.
Liskov-substitution#
Ако потърсите в Wikipedia за Liskov substitution principle, може да видите следното:
“Нека \(\phi(x)\) е стойност, която е доказуема за обектите \(x\) от тип \(T\). Тогава \(\phi(y)\) трябва да е вярно за обекти \(y\) от тип \(S\), където \(S\) е подтип на \(T\).”
…
Преведено на български, това означава, че ако имаме два класа Base
и Child
, навсякъде където използваме инстанции на Base
, трябва да можем да ги заменим с инстанции на Child
, и програмата ни да работи.
Нека разгледаме пример, в който този принцип не е спазен.
class Rectangle:
def __init__(self, x: int = 0, y: int = 0) -> None:
self.__x = x
self.__y = y
@property
def x(self) -> int:
return self.__x
@x.setter
def x(self, x: int) -> None:
self.__x = x
@property
def y(self) -> int:
return self.__y
@y.setter
def y(self, y: int) -> None:
self.__y = y
def area(self) -> int:
return self.__x * self.__y
class Square(Rectangle):
def __init__(self, x: int) -> None:
super().__init__(x, x)
rectangle = Square()
rectangle.x = 2
rectangle.y = 3
print(f'Rectangle area: {rectangle.area()}')
Rectangle area: 6
Ако в този код заместим Rectangle
с Square
, програмата няма да работи по същия начин. Това е пример за нарушаване на LSP. Методите на Rectangle
са по-общи от методите на Square
, и не всички обекти от тип Square
са обекти от тип Rectangle
.
class Base:
def foo(self, a):
pass
class Child(Base):
def foo(self, a, b):
pass
Interface segregation#
Interface segregration принципа гласи “много специфични интерфейси са по-добри от един общ интерфейс”. Това означава, че ако имаме два класа, които имат общ интерфейс, но само един от класовете го използва, то е по-добре да има два интерфейса, по един за всеки клас.
Това означава, че ако имаме даден интерфейс, който има 10 метода, но само 2 от тях се използват от класовете, които го имплементират, то е по-добре да има два интерфейса, по един за всеки клас, и да имплементирате само тези 2 метода.
Въпреки, че в Python няма интерфейси, този принцип е валиден. Нека разгледаме пример, в който този принцип не е спазен.
from abc import ABC
class Camera(ABC):
def __init__(self, name: str, resolution: str) -> None:
self.__name = name
self.__resolution = resolution
@property
def name(self) -> str:
return self.__name
@name.setter
def name(self, name: str) -> None:
self.__name = name
@property
def resolution(self) -> str:
return self.__resolution
@resolution.setter
def resolution(self, resolution: str) -> None:
self.__resolution = resolution
def take_photo(self) -> None:
pass
def take_video(self) -> None:
pass
Тук имаме два метода - take_photo
и take_video
- които биха се използвали от бъдещи Camera
и VideoCamera
класове. Това е пример за нарушаване на ISP, защото Camera
не използва take_video
, и VideoCamera
не използва take_photo
. Това означава, че Camera
и VideoCamera
не трябва да имплементират CameraInterface
, а трябва да имат по един интерфейс, който да има само един метод - take_photo
или take_video
. Допълнително, можем да направим трети интерфейс - Device
, който да държи общите данни и методи за тези устройства.
from abc import ABC
class Device(ABC):
def __init__(self, name: str, resolution: str) -> None:
self.__name = name
self.__resolution = resolution
@property
def name(self) -> str:
return self.__name
@name.setter
def name(self, name: str) -> None:
self.__name = name
@property
def resolution(self) -> str:
return self.__resolution
@resolution.setter
def resolution(self, resolution: str) -> None:
self.__resolution = resolution
class Photoable(ABC):
def take_photo(self) -> None:
pass
class Videoable(ABC):
def take_video(self) -> None:
pass
Dependency inversion#
Принципът за инверсия на зависимостите казва, че модулите от високо ниво не трябва да зависят от модули от ниско ниво, а обратното. Интерфейсите трябва да зависят от конкретните имплементации, а не обратното.
Нека разгледаме следния пример: Имаме три класа - Developer
, Tester
и ProjectcManager
. Developer
и Tester
имплементират work
метод, а ProjectManager
управлява тези инстанции.
class Developer():
def work():
pass
class Tester():
def work():
pass
class ProjectManager():
def __init__(self) -> None:
self.employees = []
def add_developer(self, developer: Developer):
self.employees.append(developer)
def add_tester(self, tester: Tester):
self.employees.append(tester)
Тук ProjectManager
зависи от Developer
и Tester
, което е нарушение на DIP. Това означава, че ако искаме да добавим нов клас, който да имплементира work
метода, но не е Developer
или Tester
, то ProjectManager
няма да може да го управлява. Това е проблем, защото ProjectManager
не трябва да знае какви класове има в системата, а само да управлява тези, които имплементират work
метода.
Решението е да създадем интерфейс Worker
, който да има само work
метода. Developer
и Tester
ще имплементират Worker
, а ProjectManager
ще зависи от Worker
.
from abc import ABC
class Worker(ABC):
def work(self) -> None:
pass
class Developer(Worker):
def work(self) -> None:
print('Developer is working')
class Tester(Worker):
def work(self) -> None:
print('Tester is working')
class EmployeeService(abc.ABC):
@abstractmethod
def work(self, worker: Worker):
...
class EmployeeServiceImpl(EmployeeService):\
...
class MockEmployeeService(EmployeeService):
...
class ProjectManager():
def __init__(self, service: EmployeeService) -> None:
self.employees = []
self.employee_service = service
def add_worker(self, worker: Worker):
self.employees.append(worker)
def manage(self) -> None:
for employee in self.employees:
employee_service.work(employee)
Clean code#
Освен SOLID принципите, съществуват и някои други правила, които спомагат за това нашия код да е четим и лесен за поддържане.
Meaningful Names#
Както споменахме в началото, в работна среда по-често ще ни се налага да четем код, отколкото да пишем. Затова доброто именуване на всички единици от кода е от изключителна важност.
Ще разгледаме няколко примера за добро и лошо именуване на променливи, функции и класове.
class Point:
def __init__(self, x: int = 0, y: int = 0) -> None:
self.__x = x
self.__y = y
@property
def x(self) -> int:
return self.__x
@x.setter
def x(self, x: int) -> None:
self.__x = x
@property
def y(self) -> int:
return self.__y
@y.setter
def y(self, y: int) -> None:
self.__y = y
def dist(self, p2: 'Point') -> float:
return ((self.__x - p2.x) ** 2 + (self.__y - p2.y) ** 2) ** 0.5
Тук основния заподозрян е метода dist
. Първото нещо, което можем да направим, е да променим името на distance_to_point
. Второто нещо, което можем да променим, е името на аргумент - вместо p2
, може да се казва other
или target
.
Името на променлива/функция/клас трябва да е максимално информативно и минимално кратко. В него не трябва да се съдържат някакви съкращения, освен ако не са много популярни. Например, id
е много популярно съкращение, което се използва във всички езици, но id
не е много информативно. Вместо това, може да се използва identifier
.
При булевите променливи и функциите които връщат булева стойност, трябва да започват с is
, has
, can
, should
, will
или did
. Другите функции трябва да бъдат именувани като глаголи, а класовете се именуват като съществителни.
Имената трябва да могат да бъдат лесно четими, но и лесно произнасяеми - например string_compare
е по-добро име от strcmp
.
Functions#
Някои допълнителни насоки за писането на функции:
Не трябва да има повече от 3 аргумента на функция. Ако има повече, трябва да се използва обект, който да съдържа всички аргументи.
Функцията трябва да прави само едно нещо. Ако прави повече, трябва да се раздели на няколко функции.
Функцията трябва да има единственно предназначение. Ако може да се използва за различни неща, трябва да се раздели на няколко функции.
Избягвайте страничните ефекти във функциите
Функциите трябват да имат висок cohesion, но нисък coupling
Cohesion - колко силно свързани са нещата, които правят функцията
Coupling - колко силно свързани са функциите между си
PEP8#
PEP8 е стандарт за писане на Python код. Той съдържа правила за именуване на променливи, функции, класове, както и правила за писане на код. Той е създаден от Python Software Foundation и е съвместим с Python 2 и 3.
Стандарта сам по себе си е доста голям, затова ще се спрем само на части от него.
Подредба на кода#
Индентацията трябва да е 4 интервала. Според PEP8, табулациите трябва да се използват само ако се пише код във файлове, които са съвместими със табулации. Във всички останали случаи трябва да се използват интервали. Максималният размера на реда трябва да е до 79 символа, докато коментарите и docstring-овете трябва да са ограничени до 72 знака.
При пренасяне на код на нов ред, пренесеният ред трябва да е подравнен спрямо отварящи delimiter, или с допълнието на 4 интервала спрямо предишния ред.
# Correct:
# Aligned with opening delimiter.
foo = long_function_name(var_one, var_two,
var_three, var_four)
# Add 4 spaces (an extra level of indentation) to distinguish arguments from the rest.
def long_function_name(
var_one, var_two, var_three,
var_four):
print(var_one)
# Hanging indents should add a level.
foo = long_function_name(
var_one, var_two,
var_three, var_four)
# Wrong:
# Arguments on first line forbidden when not using vertical alignment.
foo = long_function_name(var_one, var_two,
var_three, var_four)
# Further indentation required as indentation is not distinguishable.
def long_function_name(
var_one, var_two, var_three,
var_four):
print(var_one)
В PEP8 също пише, че новите редове трябва да са преди бинарните оператори:
# Wrong:
# operators sit far away from their operands
income = (gross_wages +
taxable_interest +
(dividends - qualified_dividends) -
ira_deduction -
student_loan_interest)
# Correct:
# easy to match operators with operands
income = (gross_wages
+ taxable_interest
+ (dividends - qualified_dividends)
- ira_deduction
- student_loan_interest)
Относно празните редове - функциите на високо ниво и класовете трябва да бъдат разделени с два празни реда, а функциите на ниско ниво и методите на класовете - с един празен ред.
Import-ите трябва да бъдат на отделни редове и да са подредени в следния ред:
Стандартните модули от Python
Външни модули
Модули от текущия проект
Между отделните групи може (а и е хубаво) да има празен ред.
# Correct:
import os
import sys
# Wrong:
import sys, os
Кавички#
В Python няма разлика между единичните и двойните кавички - PEP8 не препоръчва използването на едните пред другите. Но важно е да се използват еднакви кавички в рамките на един файл/проект.
Празни места#
Избягвайте използванто на множество празни места в кода.
# Correct:
spam(ham[1], {eggs: 2})
# Wrong:
spam( ham[ 1 ], { eggs: 2 } )
# Correct:
foo = (0,)
# Wrong:
bar = (0, )
# Correct:
if x == 4: print(x, y); x, y = y, x
# Wrong:
if x == 4 : print(x , y) ; x , y = y , x
# Correct:
ham[1:9], ham[1:9:3], ham[:9:3], ham[1::3], ham[1:9:]
ham[lower:upper], ham[lower:upper:], ham[lower::step]
ham[lower+offset : upper+offset]
ham[: upper_fn(x) : step_fn(x)], ham[:: step_fn(x)]
ham[lower + offset : upper + offset]
# Wrong:
ham[lower + offset:upper + offset]
ham[1: 9], ham[1 :9], ham[1:9 :3]
ham[lower : : upper]
ham[ : upper]
# Correct:
spam(1)
# Wrong:
spam (1)
# Correct:
dct['key'] = lst[index]
# Wrong:
dct ['key'] = lst [index]
# Correct:
x = 1
y = 2
long_variable = 3
# Wrong:
x = 1
y = 2
long_variable = 3
Именуване, част 2#
Класовете в Python следва да се именуват спрямо
CapWords
(илиPascalCase
) конвенцията.Имената на променливите и функциите следва да се именуват спрямо
snake_case
конвенцията.
Pylint#
Pylint е инструмент, който проверява дали написания от нас код съотвества на PEP8 стандарта. Можем да го инсталираме чрез pip install pylint
. Стартираме го чрез pylint
.
Пример#
Live coding demo, idea is WIP.
Comments#
Коментарите трябва да са информативни и да се използват за обясняване на нещата, които не са ясни от кода. Те не трябва да повтарят кода, а да го допълват. Използвайте коментари, за да обясните неясните неща от кода - някой regex, някакъв алгоритъм, някакъв неочакван резултат и т.н. Може да се добави примерен вход и изход към функцията.
Силно препоръчително е използването на docstrings. В тях описваме на високо ниво какво прави функцията, кавки аргументи приема и какво връща.
При спазването на дадени стандарти, можем лесно да генерираме документация за нашия код. Например, в Python можем да използваме Sphinx, който генерира HTML документация от docstrings.