Сравнение улучшения кода в Python и C++ (часть 1)
Данная статья посвящена теме улучшения кода в языках Python и C++, а именно обработке ошибок, валидации, аннотациям типов, повышению отказоустойчивости и переиспользованию кода.
Дополнительные материалы
Для скачивания материалов необходимо войти или зарегистрироваться
Файлы также можно получить в Telegram-боте по коду: 108905
Реклама
Краткое описание
Данная статья посвящена теме улучшения кода в языках Python и C++, а именно обработке ошибок, валидации, аннотациям типов, повышению отказоустойчивости и переиспользованию кода.
Вступление
Всем доброго дня! В предыдущей статье Сравнение hashmap C/C++ с dict Python (часть 3) из рубрики "Сравнение Python и C/C++" мы разобрали простую версию телефонной книги на C++. Сегодня мы с вами будем улучшать код телефонной книги Python версии, делать лучше структуру кода, улучшать читаемость кода, повышать отказоустойчивость, использовать аннотации типов, обработку ошибок и валидировать код. Открывайте свои IDE и приготовьтесь делать нашу электронную записную книгу лучше.
Улучшение кода в Python
Что такое аннотация типов данных?
Аннотация типов нам нужна для того, чтобы понимать какой тип данных имеет переменная, что вернёт функция, а также для удобной работы в IDE, поскольку благодаря ананотации, IDE понимает, что это за объект и может давать корректные подсказки по доступным действиям или методам. Это повышает читаемость кода, упрощает поддержку и многое другое.
Сама по себе аннотация типов является своего рода "соглашением разработчиков", что я имею ввиду? Если мы в функции указем параметр с типом данных str
, но передадим int
, то сам Python никак на это не отреагирует (если в коде не произойдёт исключения, конечно), зато нас о несовпадении типов предупредит IDE.
Однако, в Python есть и инструменты, использующие аннотацию типов по прямому назначению, подобно указанию типа данных в языке C/C++. Самым ярким примером будет использование @dataclass
или Pydantic-моделей
:```python
@dataclass
class Example:
name: str
В данном примере, если мы создадим экземпляр класса Example(name=123)
, произойдёт ошибка.
Аннотация в действии
Давайте начнём с функции add_contact
, которая добавляет новый контакт в телефонную книгу:
def add_contact(firstname, lastname, number):
"""Функция добавит новый контакт в книгу"""
contact = f'{firstname} {lastname}'
phone_book[contact] = number
На первый взгляд это простая функция, в которой всё хорошо. Однако она имеет ряд проблем со структурой кода и нуждается в переработке. А именно:
1) Было бы неплохо назначить параметрам функции их типы;
2) Также наша функция ничего не возвращает, поэтому нужно показать что она вернёт None
.
Давайте перепишем нашу функцию:
def add_contact(firstname: str, lastname: str, number: int) -> None:
"""Функция добавит новый контакт в книгу"""
contact = f'{firstname} {lastname}'
phone_book[contact] = number
Теперь мы точно видим, что имя и фамилия имеют тип данных str
(строка), а телефонный номер имеет тип int
(целое число). Также мы видим, что функция вернёт -> None
так как у неё нет данных, которая она вернёт.
Давайте теперь посмотрим как будет выглядеть функция, которая обязательно должна что-то вернуть.
Следующая функция get_number
должна получить телефонный номер по имени и фамилии контакта, для этого необходимо ее переписать следующим образом:
def get_number(firstname: str, lastname: str) -> str | None:
"""Функция получает номер по фио"""
contact = f'{firstname} {lastname}'
if contact in phone_book.keys():
return phone_book[contact]
print('Контакт не обнаружен!')
Теперь мы видим что у параметров firstname
и lastname
появились типы данных str
, а возвращаем мы -> str | None
номер в виде строки или None
. Также я решил вместо возврата строки 'Контакт не обнаружен!'
выводить её функцией print
. Так мы будем понимать, что контакт не обнаружен в телефонной книге и при этом прилетит None
, что полезно для логики программы.
Остальные функции я перепишу по аналогии и покажу их в итоговом варианте кода. Я считаю, что лучше сэкономить время на повторении одних и тех же действий.
Улучшение Python кода
Для чего нам нужно улучшать свой код? Хорошим и точным ответом будет: для его оптимизации, скорости, уменьшения потребления ресурсов ПК и т.д.
Функция get_number
Давайте возьмём для примера функцию get_number
и посмотрим как её можно улучшить.
def get_number(firstname, lastname):
"""Функция получает номер по фи"""
contact = f'{firstname} {lastname}'
if contact in phone_book.keys():
return phone_book[contact]
return 'Контакт не обнаружен!'
Внутри функции мы используем f-строку
для создания строки "фамилии имени". Это правильный, современный и быстрый способ создать строку. Любой из остальных способов создания строки на современной версии Python я бы заменил на f-strings
.
Теперь давайте пойдём дальше вниз по коду. Мы проверяем в строке ключей телефонной книги if contact in phone_book.keys()
есть ли "фамилия имя" нашего контакта. Тут я уже вижу ошибку потому что нам не нужны все ключи словаря чтобы проверить один ключ. Получение всех ключей телефонной книги, а их там может быть очень много, что будет очень накладной операцией в плане производительности. Это было бы приемлемо, если мы должны были пройти по всем ключам книги и сделать операцию с каждым ключом. Для получения значения по ключу в dict Python есть метод get()
, который вернёт None
в случае если ключа нет. Также мы зависим от глобальной переменной phone_book
, которую лучше передать функции в качестве аргумента.
Давайте внесем соответствующие изменения в наш код:
def get_number(phone_book: dict[str: str], firstname: str, lastname: str) -> str:
"""Функция получает номер по фио"""
contact = f'{firstname} {lastname}'
get_contact = phone_book.get(contact)
if get_contact:
return get_contact
print('Контакт не обнаружен!')
Этим улучшением мы добились двух вещей:
- Вместо двух операций мы сделали одну.
Переменнаяget_contact
используется как для проверки в условииif get_contact:
так и для возврата контактаreturn get_contact
. - Мы убрали лишнюю операцию, которая получает все ключи из телефонной книги, что улучшает производительность в случае, если данных очень много.
Мы видим, что телефонная книга передаётся в качестве аргумента, а её параметр имеет тип dict
, в котором хранятся ключи str
. Теперь функция не зависит от имени книги и становится универсальной и легко переносимой в разные проекты и модули. Ну и ещё я сделал отступы для блоков кода для более удобной читаемости.
Функция get_all_contacts
Следующая функция get_all_contacts()
ничего не возвращает, а выводит на экран все контакты функцией print()
. Тут есть одна маленькая ошибка - из её названия следует, что она получает все контакты, а не выводит их на экран. Тут возможны три варианта улучшения: переименовать функцию согласно её функциональности или сделать её генераторной с помощью yield
или возвращать список контактов. Я решил использовать генераторную функцию с yield
.
Давайте посмотрим как изменилась функция:
def get_all_contacts(phone_book: dict[str: str]) -> Generator[str, str, str]:
"""Генераторная функция для возврата всех контактов"""
for contact, number in phone_book.items():
yield f'{contact} - {number}'
Обратите внимание, теперь мы используем генераторную функцию и она вернёт объект Generator
. Это увеличивает производительность особенно на больших объёмах данных, эффективно использует память при создании объектов на лету и использует ленивые вычисления. Я не возвращаю список поскольку мы не используем с этими данными других операций кроме как вывода на экран. Если бы нам нужно было изменить список, то тогда мы бы вернули список.
Декоратор print_items
Для вывода значений на экран мы можем либо использовать цикл for
при вызове функции генератора, что не очень практично и удобно, а можем написать декоратор, который будет печатать значения из функции генератора. Давайте напишем декоратор и посмотрим на новый код.
def print_items(func: Callable[..., Generator[Any, None, None]]
)-> Callable[..., Generator[Any, None, None]]:
"""Функция декоратор для печати значений из генератора на экран"""
def wrapper(*args, **kwargs) -> None:
for value in func(*args, **kwargs):
print(value)
return wrapper
@print_items
def get_all_contacts(phone_book: dict[str: str]) -> Generator[str, str, str]:
"""Генераторная функция для возврата всех контактов"""
for contact, number in phone_book.items():
yield f'{contact} - {number}'
Мы видим, что у нас есть декоратор print_items
, у которого в качестве параметра выступает функция с генератором func: Callable[..., Generator[Any, None, None]
.
Внутри декоратора мы проходим по генератору и выводим значения из него. Декоратор вернёт None
так как он только печатает значение на экран функцией print()
. Также саму функцию get_all_contacts()
мы оборачиваем в декоратор @print_items
.
Функция del_all_contacts
Следующую функцию, которую я бы поменял - это del_all_contacts()
.
def get_all_contacts():
"""Функция получает список всех контактов"""
for contact, number in phone_book.items():
print(f'{contact} - {number}')
Мы создаём две лишних операции:
- Первая проблема - мы делаем полную копию телефонной книги
copy_contacts = phone_book.copy()
. Если там 2000 контактов, то это будет накладно по ресурсам и производительности. - Вторая проблема - вопрос, для чего мы это делаем? Правильно, для ещё одной бесполезной операции
copy_contacts.keys()
, которая нам нужна чтобы пройти цикломfor
по ключам из телефонной книги. - Третья проблема - в конце мы удаляем каждую запись телефонной книги по одной штуке
del phone_book[contact]
.
Итого три операции, которые можно сделать за одну. Давайте перепишем нашу функцию и посмотрим на неё:
def del_all_contacts(phone_book: dict[str: str]) -> None:
"""Функция удалит все контакты из телефонной книги"""
phone_book.clear()
Как мы видим, всё это вмещается в одну строку с использованием встроенного в Python метода .clear()
, который просто очистит словарь. Если есть хорошие встроенные методы и функции всегда используйте их, потому что они очень эффективны. Также они будут экономить ваше время на разработку.
Во все остальные функции, где требовалось добавить phone_book: dict[str: str]
в качестве аргумента, я уже добавил. Зачем показывать одинаковые изменения по блоку, если вы увидите их в итоговом коде ниже?
Обработка ошибок
Теперь, когда наш код выглядит причёсанным, более читаемым и оптимизированным, мы можем приступить к его проверке на прочность =) Давайте попробуем вместо типа dict
скормить функции add_contact()
тип данных list
.
# список со словарями для телефонной книги
phone_book = [{'Николай Николаев': '+72423535354'}, {'Виталий Краснов': '84424434345'}]
# добавим новый контакт
add_contact(
phone_book=phone_book,
firstname='Сергей',
lastname='Сергеев',
number='83201345024'
)
# выведем количество контактов
print(f'Количество контактов: {len(phone_book)}')
Запустим наш код через IDE, нажав на Run Python File.
Traceback (most recent call last):
File "/home/user_name/Документы/Сode/phone_book.py", line 65, in <module>
nuber = get_number(
^^^^^^^^^^^
File "/home/user_name/Документы/Сode/phone_book.py", line 16, in get_number
get_contact = phone_book.get(contact)
^^^^^^^^^^^^^^
AttributeError: 'list' object has no attribute 'get'
Мы видим ошибку AttributeError
, так как у list
нет метода .get()
. Наша программа упала полностью, а вызовы оставшихся функций выполнены не будут. Чтобы этого не произошло, а программа не упала и выдала нам подсказку, нужно обрабатывать ошибки. Тут нам понадобится конструкция try ... except
, которая будет помогать нашей программе не упасть, а разработчику и пользователю давать подсказки, где есть проблема в коде. Теперь давайте добавим в функцию add_contact()
обработку ошибок и посмотрим на изменения в коде.
def add_contact(phone_book: dict[str: str], firstname: str, lastname: str,
number: int) -> None:
"""Функция добавит новый контакт в книгу"""
try:
if not isinstance(phone_book, dict):
raise TypeError('Тип записной книги должен быть dict')
elif not isinstance(firstname, str):
raise TypeError('Имя должно иметь тип str')
elif not isinstance(lastname, str):
raise TypeError('Фамилия должна иметь тип str')
contact = f'{firstname} {lastname}'
phone_book[contact] = number
except TypeError as err:
message_err =f'{err.__class__.__name__}: {err}'
print(message_err)
Как мы видим в if not isinstance(phone_book, dict)
, если тип данных book
не dict
мы вызываем ошибку TypeError
, в которой прописываем сообщение, что указывает на проблему 'Тип записной книги должен быть dict'
. Похожую обработку мы сделали для имени и фамилии, только там типом данных должен быть str
. Строка except TypeError as err:
отлавливает ошибку TypeError
. Строка ниже message_err = f'{err.__class__.__name__}: {err}'
формирует само сообщение об ошибке, которое состоит из названия ошибки err.__class__.__name__
и её сообщения в тексте err
. Ну и в конце мы выводим само сообщение функцией print()
.
Посмотрим, как себя поведёт программа, запустив её в VSCode:
TypeError: Тип записной книги должен быть dict
Количество контактов: 2
Как мы видим, наша программа вывела понятную информацию об ошибке. Также наш код ниже продолжил свою работу и программа не упала полностью.
Сейчас я перепишу остальные функции под обработку ошибок, которые вы увидите в финальной версии кода. Я не буду заострять внимание на обработке ошибок в каждой функции, ибо это слишком долго расписывать. Вместо этого вы можете сами взглянуть на финальную версию кода и подробно ознакомиться с каждой ошибкой в документации Concrete exceptions. Я использую Python версии 3.12 поэтому скинул документацию на свою версию.
Переиспользование кода
Написав обработчики для нескольких функций, я заметил, что они одинаковые, а PEP 20 или "Дзен питона" учит нас не повторять код, а переиспользовать его.
Что же можно сделать для функционального подхода в программировании? Есть вариант написать главную функцию, в которой вы опишете обработку ошибок и вызовете все интересующие функции, но данный способ слишком тугой, когда речь заходит об использовании функций по отдельности, а не группой в последовательности. Поэтому я рекомендую воспользоваться декоратором, который будет легко работать для каждой функции и отлавливать ошибки в ней.
Давайте напишем наш декоратор для отлавливания ошибок и посмотрим на его код:
def catching_exceptions(func: Callable) -> Callable:
"""Функция декоратор для отлова ошибок"""
def wrapper(*args, **kwargs) -> None:
try:
return func(*args, **kwargs)
except TypeError as err:
message_err =f'{err.__class__.__name__}: {err}'
print(message_err)
return wrapper
Декоратор catching_exceptions
принимает функцию в качестве аргумента и возвращает функцию. Об этом говорит объект Callable
в аннотации и возврате декоратора. Мы видим, что с помощью декоратора выполняется отлов ошибки TypeError
, которая нужна абсолютно для всех функций. Также мы формируем сообщение ошибки и выводим его на экран.
Мы экономим данные строки кода для всех функций. В самих функциях остались только raise
ошибок и проверка на типы. Это нужно, потому что параметры в разных функциях разные и за их именами сложно следить в декораторах.
Давайте посмотрим, как изменилась функция add_contact()
:
@catching_exceptions
def add_contact(phone_book: dict[str: str], firstname: str, lastname: str,
number: int) -> None:
"""Функция добавит новый контакт в книгу"""
if not isinstance(phone_book, dict):
raise TypeError('Тип записной книги должен быть dict')
elif not isinstance(firstname, str):
raise TypeError('Имя должно иметь тип str')
elif not isinstance(lastname, str):
raise TypeError('Фамилия должна иметь тип str')
contact = f'{firstname} {lastname}'
phone_book[contact] = number
Функции-валидаторы
Мы видим, что функция стала более лаконична. Однако нужно понимать, что мы убрали только часть повторяющегося кода. Напрашивается написание двух валидаторов. Для типа записной книги dict
и для фи lastname
и firstname
. Они должны идти отдельно, так как проверка на str
имени и фамилии встречается не в каждой функции.
Давайте напишем два валидатора и посмотрим на наш новый код:
def type_phone_book_valid(phone_book: dict[str: str]) -> None:
"""Функция валидатор типа телефонной книги"""
if not isinstance(phone_book, dict):
raise TypeError('Тип записной книги должен быть dict')
def names_valid(firstname: str, lastname: str) -> None:
"""Функция валидатор фи"""
if not isinstance(firstname, str):
raise TypeError('Имя должно иметь тип str')
if not isinstance(lastname, str):
raise TypeError('Фамилия должна иметь тип str')
@catching_exceptions
def add_contact(phone_book: dict[str: str], firstname: str, lastname: str,
number: int) -> None:
"""Функция добавит новый контакт в книгу"""
type_phone_book_valid(phone_book=phone_book)
names_valid(firstname=firstname, lastname=lastname)
contact = f'{firstname} {lastname}'
phone_book[contact] = number
Теперь мы видим, что у нас появились две функции type_phone_book_valid()
и names_valid()
для удобной валидации типа записной книжки и типов имён. На самом деле можно было бы расширить валидацию, добавив ещё проверок. К примеру, можно было добавить проверку на пустую строку. Но моя задача в данном случае - показать и направить на процесс улучшения кода, а не предусмотреть все возможные проблемы кода. Я внедрю функции валидации во все функции телефонной книги и покажу всё в итоговом результате.
Было бы неплохо строки, подобные 'Тип записной книги должен быть dict'
, положить в errors.json
или erros.yml
и переиспользовать их. Но об этом как-нибудь в другой раз.
Посмотрев на код, я понимаю, что не хватает сообщений для оповещения об успешных операциях. Сейчас пользователь не понимает, успешно ли он создал контакт или нет. Программа при успехе просто хранит молчание. Чтобы это исправить, в конце функции add_contact()
добавим строку print(f'Контакт {contact} с номером {number} успешно добавлен.')
. В случае успешного запуска вы увидите строку вроде такой Контакт Сергей Сергеев с номером 83201345024 успешно добавлен.
Я добавлю строки об успешной операции в каждую функцию где это необходимо. Затем я покажу вам весь код, что у меня получился и запущу его.
Итоговый результат
from typing import Any, Callable, Generator
def type_phone_book_valid(phone_book: dict[str: str]) -> None:
"""Функция валидатор типа телефонной книги"""
if not isinstance(phone_book, dict):
raise TypeError('Тип записной книги должен быть dict')
def names_valid(firstname: str, lastname: str) -> None:
"""Функция валидатор фи"""
if not isinstance(firstname, str):
raise TypeError('Имя должно иметь тип str')
if not isinstance(lastname, str):
raise TypeError('Фамилия должна иметь тип str')
def catching_exceptions(func: Callable) -> Callable:
"""Функция декоратор для отлова ошибок"""
def wrapper(*args, **kwargs) -> None:
try:
return func(*args, **kwargs)
except TypeError as err:
message_err =f'{err.__class__.__name__}: {err}'
print(message_err)
return wrapper
@catching_exceptions
def add_contact(phone_book: dict[str: str], firstname: str, lastname: str,
number: int) -> None:
"""Функция добавит новый контакт в книгу"""
type_phone_book_valid(phone_book=phone_book)
names_valid(firstname=firstname, lastname=lastname)
contact = f'{firstname} {lastname}'
phone_book[contact] = number
print(f'Контакт {contact} с номером {number} успешно добавлен.')
@catching_exceptions
def get_number(phone_book: dict[str: str], firstname: str, lastname: str) -> str:
"""Функция получает номер по фио"""
type_phone_book_valid(phone_book=phone_book)
names_valid(firstname=firstname, lastname=lastname)
contact = f'{firstname} {lastname}'
get_contact = phone_book.get(contact)
if get_contact:
return get_contact
print('Контакт не обнаружен!')
def print_items(func: Callable[..., Generator[Any, None, None]]) -> None:
"""Функция декоратор для печати значений из генератора на экран"""
def wrapper(*args, **kwargs) -> None:
for value in func(*args, **kwargs):
print(value)
return wrapper
@catching_exceptions
@print_items
def get_all_contacts(phone_book: dict[str: str]) -> Generator[str, str, str]:
"""Генераторная функция для возврата всех контактов"""
type_phone_book_valid(phone_book=phone_book)
for contact, number in phone_book.items():
yield f'{contact} - {number}'
@catching_exceptions
def del_contact(phone_book: dict[str: str], firstname: str,
lastname: str) -> None:
"""Функция удалит контакт по фио"""
type_phone_book_valid(phone_book=phone_book)
names_valid(firstname=firstname, lastname=lastname)
contact = f'{firstname} {lastname}'
del phone_book[contact]
print(f'Контакт {contact} успешно удалён.')
@catching_exceptions
def del_all_contacts(phone_book: dict[str: str]) -> None:
"""Функция удалит все контакты из телефонной книги"""
type_phone_book_valid(phone_book=phone_book)
phone_book.clear()
print('Теелефонная книга успешно очищена от контактов.')
if __name__ '__main__':
phone_book = {
'Николай Николаев': '+72423535354',
'Виталий Краснов': '84424434345'
}
# получим номер контакта
nuber = get_number(
phone_book=phone_book,
firstname='Николай',
lastname='Николаев'
)
print(nuber)
print('--------------------------------')
# добавим новый контакт
add_contact(
phone_book=phone_book,
firstname='Сергей',
lastname='Сергеев',
number='83201345024'
)
print('--------------------------------')
# выведем количество контактов
print(f'Количество контактов: {len(phone_book)}')
print('--------------------------------')
# выведем список всех контактов
get_all_contacts(phone_book=phone_book)
print('--------------------------------')
# удалим контакт
del_contact(phone_book=phone_book, firstname='Виталий', lastname='Краснов')
print('--------------------------------')
# снова выведем список всех контактов
get_all_contacts(phone_book=phone_book)
print('--------------------------------')
# удалим все контакты
del_all_contacts(phone_book=phone_book)
# убедимся, что телефонная книга пуста
print(phone_book)
print(type(phone_book.items()))
Итогововый результат работы кода в terminal:
+72423535354
--------------------------------
Контакт Сергей Сергеев с номером 83201345024 успешно добавлен.
--------------------------------
Количество контактов: 3
--------------------------------
Николай Николаев - +72423535354
Виталий Краснов - 84424434345
Сергеев - 83201345024
--------------------------------
Контакт Виталий Краснов успешно удалён.
--------------------------------
Николай Николаев - +72423535354
Сергеев - 83201345024
--------------------------------
Теелефонная книга успешно очищена от контактов.
{}
<class 'dict_items'>
Наш код отработал как надо. Дополнительно, теперь мы видим сообщения об успешном добавлении и удалении контакта, а также сообщение об успешном очищении телефонной книги. Это делает процесс использования телефонной книги более удобным и понятным. Ну и посмотрите на сам итоговый код проекта: он стал более защищённым от ошибок, понятным и удобным, плюс мы избавились от лишних строк. Основным недостатком данного кода является то, что весь код находится в одном файле. Поэтому в будущих статьях я буду разбивать проект на несколько .py
файлов. Впереди эту телефонную книгу ждёт ещё много интересных преобразований =)
Анонс на следующие статьи
Следующая часть статьи выйдет 26.12.24, где я буду улучшать код телефонной книги в версии С++. В версию C++ я тоже добавлю валидацию, обработку ошибок и расскажу про то, как там можно аннотировать типы.
Дальше будет ещё интереснее :)
Заключение
- Поговорили об аннотации типов в Python;
- Узнали, как можно улучшить код;
- Внедрили обработку ошибок;
- Переиспользовали повторяющийся код;
Все статьи