Реализация приложения — получение GPS координат
Реализуем в первую очередь получение GPS-координат, coordinates.py
:
from dataclasses import dataclass
from subprocess import Popen, PIPE
from exceptions import CantGetCoordinates
@dataclass(slots=True, frozen=True)
class Coordinates:
longitude: float
latitude: float
def get_gps_coordinates() -> Coordinates:
"""Returns current coordinates using MacBook GPS"""
process = Popen(["whereami"], stdout=PIPE)
(output, err) = process.communicate()
exit_code = process.wait()
if err is not None or exit_code != 0:
raise CantGetCoordinates
output_lines = output.decode().strip().lower().split("\n")
latitude = longitude = None
for line in output_lines:
if line.startswith("latitude:"):
latitude = float(line.split()[1])
if line.startswith("longitude:"):
longitude = float(line.split()[1])
return Coordinates(longitude=longitude, latitude=latitude)
if __name__ == "__main__":
print(get_gps_coordinates())
Хочу обратить внимание тут вот на что. Если что-то пошло не так с процессом получения координат — мы не возвращаем какую-то ерунду вроде None
. Мы возбуждаем (райзим, от англ. raise) исключение. Причём исключение не какое-то системное вроде ValueError
, а наш собственный тип исключения, который мы назвали CantGetCoordinates
и положили в специальный модуль, куда мы будем класть исключения exceptions.py
:
class CantGetCoordinates(Exception):
"""Program can't get current GPS coordinates"""
Почему не ValueError
, а свой тип исключений? Чтобы разделять обычные питоновские ValueError
от конкретно нашей ситуации с невозможностью получить координаты. Явное лучше неявного.
Почему исключение, а не возврат None
? Потому что если у функции есть нормальный сценарий работы и ненормальный, то есть исключительный, то исключительный сценарий должен использовать исключения, а не возвращать какую-то ерунду вроде False
, 0
, None
, tuple()
. Исключительная ситуация должна возбуждать исключение, и уже на уровне выше нашей функции мы должны решить, что с этой исключительной ситуацией делать. Код, который будет вызывать нашу функцию get_gps_coordinates
, решит, что делать с исключительной ситуацией, на каком уровне и как эта ситуация должна быть обработана.
Отлично. Функция отдаёт сейчас точные координаты, которые я не хочу раскрывать, давайте введём в приложение конфиг config.py
и в нём зададим, использовать точные координаты или примерные. Я буду использовать примерные координаты. Погода от этого не изменится, просто в другой район города попаду.
config.py
:
USE_ROUNDED_COORDS = True
coordinates.py
:
from dataclasses import dataclass
from subprocess import Popen, PIPE
import config
from exceptions import CantGetCoordinates
@dataclass(slots=True, frozen=True)
class Coordinates:
longitude: float
latitude: float
def get_gps_coordinates() -> Coordinates:
"""Returns current coordinates using MacBook GPS"""
process = Popen(["whereami"], stdout=PIPE)
output, err = process.communicate()
exit_code = process.wait()
if err is not None or exit_code != 0:
raise CantGetCoordinates
output_lines = output.decode().strip().lower().split("\n")
latitude = longitude = None
for line in output_lines:
if line.startswith("latitude:"):
latitude = float(line.split()[1])
if line.startswith("longitude:"):
longitude = float(line.split()[1])
if config.USE_ROUNDED_COORDS: # Добавили округление координат
latitude, longitude = map(lambda c: round(c, 1), [latitude, longitude])
return Coordinates(longitude=longitude, latitude=latitude)
if __name__ == "__main__":
print(get_gps_coordinates())
Отлично. Обратите внимание — мы не полагаемся здесь на то, на какой строке будет значение широты и долготы в выдаче команды whereami
. Мы ищем нужную строку во всех возвращаемых строках, не полагаясь на то, будут это первые строки или нет. Получается более надёжное решение на случай смены порядка строк в whereami
.
Теперь проведём рефакторинг, поделив большую, делающую слишком много всего функцию get_gps_coordinates
на несколько небольших простых функций:
from dataclasses import dataclass
from subprocess import Popen, PIPE
from typing import Literal
import config
from exceptions import CantGetCoordinates
@dataclass(slots=True, frozen=True)
class Coordinates:
latitude: float
longitude: float
def get_gps_coordinates() -> Coordinates:
"""Returns current coordinates using MacBook GPS"""
coordinates = _get_whereami_coordinates()
return _round_coordinates(coordinates)
def _get_whereami_coordinates() -> Coordinates:
whereami_output = _get_whereami_output()
coordinates = _parse_coordinates(whereami_output)
return coordinates
def _get_whereami_output() -> bytes:
process = Popen(["whereami"], stdout=PIPE)
output, err = process.communicate()
exit_code = process.wait()
if err is not None or exit_code != 0:
raise CantGetCoordinates
return output
def _parse_coordinates(whereami_output: bytes) -> Coordinates:
try:
output = whereami_output.decode().strip().lower().split("\n")
except UnicodeDecodeError:
raise CantGetCoordinates
return Coordinates(
latitude=_parse_coord(output, "latitude"),
longitude=_parse_coord(output, "longitude")
)
def _parse_coord(
output: list[str],
coord_type: Literal["latitude"] | Literal["longitude"]) -> float:
for line in output:
if line.startswith(f"{coord_type}:"):
return _parse_float_coordinate(line.split()[1])
else:
raise CantGetCoordinates
def _parse_float_coordinate(value: str) -> float:
try:
return float(value)
except ValueError:
raise CantGetCoordinates
def _round_coordinates(coordinates: Coordinates) -> Coordinates:
if not config.USE_ROUNDED_COORDS:
return coordinates
return Coordinates(*map(
lambda c: round(c, 1),
[coordinates.latitude, coordinates.longitude]
))
if __name__ == "__main__":
print(get_gps_coordinates())
Кода стало больше, функций стало больше, но код стал проще читаться и будет проще сопровождаться. Если бы мы сейчас писали тесты, то убедились бы ещё и в том, что этот код легче обложить тестами, чем предыдущий вариант с одной большой функцией, делающей всё подряд.
Функции, имена которых начинаются с подчёркивания — не предназначены для вызова извне модуля, то есть они вызываются только соседними функциями модуля coordinates.py
.
Почему много коротких функций это лучше, чем одна большая функция? Потому что для того, чтобы понять, что происходит внутри функции на 50 строк, надо прочитать 50 строк. А если эти 50 строк разбить на пару меньших функций и понятным образом эти пару функций назвать, то нам понадобится прочесть всего пару строк с вызовами этой пары функций и всё. Прочесть пару строк легче, чем 50. А если нам нужны детали реализации какой-то из этих меньших функций, мы всегда можем в неё провалиться и посмотреть, что внутри.
Функция get_gps_coordinates
тут максимально проста — она получает координаты и затем округляет их и возвращает, всё. Два вызова понятно названных функций вместо длинного сложного кода, как было раньше.
Также обратите внимание — абсолютно все функции типизированы, все принимаемые аргументы функций типизированы и все возвращаемые значения тоже типизированы. Причём типизированы максимально конкретными типами.
Эта логика реализована без классов, на обычных функциях. Это нормально. Не нужно использовать ООП просто для того, чтобы у вас были классы. От того, что мы обернём несколько описанных здесь функций в класс — никакого нового полезного качества в нашем коде не появится, просто вместо функций будет класс. В таком случае вовсе не нужно использовать классы.
Обратите внимание также, как в функции _parse_float_coordinate
обработана ошибка ValueError
, которая может возникать, если вдруг координаты не получается привести из строки к типу float
— мы возбуждаем (райзим) исключение своего типа CantGetCoordinates
. В любой ситуации, когда нам не удалось получить координаты из результатов команды whereami
мы получаем такое исключение и можем обработать (или не обрабатывать) его в коде, который будет вызывать нашу верхнеуровневую функцию get_gps_coordinates
. Про работу с исключениями более подробно мы поговорим в отдельном материале.