Разработка более крупного приложения
До этого момента в материалах курса мы рассмотрели большое количество возможностей Python.
Курс Introduction to Programming познакомил вас с управляющими структурами, такими как while и for, функциями и базовыми структурами данных, такими как списки, кортежи и словари. В принципе, этих инструментов достаточно, чтобы выразить на Python все, что программист может захотеть выразить.
В этом Advanced Course in Programming, начиная с части 8 материала, вы познакомились с классами и объектами. Давайте на минуту задумаемся, когда и почему они нужны, если базовых инструментов из частей 1-7 должно быть достаточно.
Управление сложностью
Объекты и классы вовсе не обязательны в каждом контексте программирования. Например, если вы пишете небольшой скрипт для одноразового использования, объекты обычно избыточны. Однако когда вы программируете что-то более крупное и сложное, объекты становятся очень полезными.
Когда сложность программ растет, количество деталей быстро становится неуправляемым, если программа не организована каким-либо систематическим образом. Даже некоторые из более сложных упражнений этого курса до сих пор выиграли бы от примеров, показанных в этой части материала.
На протяжении десятилетий концепция разделения ответственности была одним из центральных принципов программирования и более широкой области информатики. Цитата из Wikipedia:
Разделение ответственности — это принцип проектирования, при котором компьютерная программа делится на отдельные части так, чтобы каждая часть отвечала за свою область. Область ответственности — это набор информации, влияющей на код компьютерной программы.
Разделение программы на части, каждая из которых отвечает за свою задачу, помогает управлять неизбежной сложностью компьютерной программы.
Функции — один из способов организовать программу в отдельные управляемые целые. Вместо написания одного большого скрипта идея состоит в том, чтобы сформулировать небольшие, отдельно проверяемые функции, каждая из которых решает некоторую часть более крупной задачи.
Другой распространенный подход к управлению крупными программами — объекты и принципы объектно-ориентированного программирования. У обоих подходов есть преимущества и недостатки, и у каждого программиста есть свои предпочтения. Как мы уже видели, объекты и классы позволяют собрать все данные и код, который обрабатывает эти данные, в одной единице — в атрибутах и методах объекта. Кроме того, объекты дают способ инкапсулировать данные, которыми они управляют, чтобы другим частям программы не приходилось заботиться о внутренних деталях объекта.
Разбор примера: телефонная книга
Как программу следует делить на классы и объекты? Это совсем не простой вопрос с единственным правильным ответом, поэтому мы продолжим на примере. В пятой части вы завершили приложение телефонной книги, а теперь реализуем нечто похожее с использованием принципов объектно-ориентированного программирования.
Следуя принципу разделения ответственности, программу следует делить на части, каждая из которых отвечает за свою область. В объектно-ориентированном программировании это соответствует принципу единственной ответственности. Не углубляясь в тонкости, его основная цель понятна из названия: один класс и созданные на его основе объекты должны иметь одну ответственность в программе.
Объектно-ориентированное программирование часто используется как способ моделирования объектов и явлений реального мира. Один объект реального мира моделируется одним классом в программном коде. В случае телефонной книги такими объектами могут быть
- человек
- имя
- номер телефона
Имя и номер телефона можно рассматривать просто как кусочки данных, которые не заслуживают собственных классов, но человек — это отдельная физическая сущность в реальном мире, и в мире программирования она могла бы работать как класс. Объект Person отвечал бы за связывание имени и привязанных к нему телефонных номеров.
Сама телефонная книга тоже может быть хорошим кандидатом для класса. Ее ответственн ость — управлять разными объектами Person и данными, которые они содержат.
Теперь мы очертили ядро нашего приложения: телефонная книга и человек составляют программную логику нашего приложения, или кратко логику приложения. Нашему приложению понадобятся и другие классы.
Обычно хорошая идея — держать любое взаимодействие с пользователем отдельно от логики приложения. В конце концов, это самостоятельная ответственность. Поэтому помимо ядра логики приложения наша программа должна содержать класс, который обрабатывает пользовательский интерфейс.
Кроме того, у нашей телефонной книги должен быть способ постоянного хранения данных между запусками. Работа с файлами — опять же явно отдельная ответственность, поэтому она заслуживает собственного класса.
Теперь, когда у нас есть схема базовых компонентов программы, возникает вопрос: с чего начать программирование? И снова здесь нет правильного или неправильного ответа, но часто хорошая идея — начать с какой-нибудь части логики приложения.
Шаг 1: набросок логики приложения
Начнем с класса PhoneBook. Каркас реализации мог бы выглядеть так:
class PhoneBook:
def __init__(self):
self.__persons = []
def add_number(self, name: str, number: str):
pass
def get_numbers(self, name: str):
pass
Этот класс состоит из списка persons и методов для добавления и получения данных.
С одним человеком может быть связано несколько номеров, поэтому реализуем внутреннюю структуру persons с помощью словаря. Словарь позволяет искать ключи по имени, а значением, связанным с ключом словаря, может быть список. Пока кажется, что нам не нужен отдельный класс для представления человека — достаточно записи в словаре.
Реализуем методы, перечисленные выше, и проверим нашу телефонную книгу:
class PhoneBook:
def __init__(self):
self.__persons = {}
def add_number(self, name: str, number: str):
if not name in self.__persons:
# add a new dictionary entry with an empty list for the numbers
self.__persons[name] = []
self.__persons[name].append(number)
def get_numbers(self, name: str):
if not name in self.__persons:
return None
return self.__persons[name]
# code for testing
phonebook = PhoneBook()
phonebook.add_number("Eric", "02-123456")
print(phonebook.get_numbers("Eric"))
print(phonebook.get_numbers("Emily"))Это должно вывести следующее:
['02-123456'] None
Метод get_numbers возвращает None, если имя не включено в телефонную книгу. Если имя найдено, он возвращает список номеров, привязанных к имени.
Каждый раз, когда вы вносите изменения в программу, всегда стоит проверить, что код работает как ожидается, прежде чем переходить к другим изменениям. Код, используемый для проверки, обычно вскоре удаляется, и поэтому может показаться, что писать какие-либо проверки вообще не стоит. В большинстве случаев это не так. Тестирование необходимо для хорошего результата программирования.
Ошибка в программе должна быть поймана и исправлена как можно раньше. Если вы привыкнете проверять работу практически каждой новой строки кода, вы обнаружите, что ошибки обычно легко найти и исправить, потому что можно быть довольно уверенным, что ошибка вызвана самым последним изменением. Если проверять программу только после добавления десятков строк кода, потенциальных источников ошибок тоже становится в десятки раз больше.
Шаг 2: набросок пользовательского интерфейса
Когда ядро логики приложения готово, пора реализовать текстовый пользовательский интерфейс. Нам понадобится новый класс, PhoneBookApplication, со следующей начальной функциональностью:
class PhoneBookApplication:
def __init__(self):
self.__phonebook = PhoneBook()
def help(self):
print("commands: ")
print("0 exit")
def execute(self):
self.help()
while True:
print("")
command = input("command: ")
if command == "0":
break
application = PhoneBookApplication()
application.execute()Эта программа пока делает немного, но разберем содержимое. Метод-конструктор создает новую PhoneBook, которая хранится в приватном атрибуте. Метод execute(self) запускает текстовый пользовательский интерфейс программы, ядром которого является цикл while: он продолжает запрашивать у пользователя команды, пока тот не введет команду выхода. Также есть метод для инструкций, help(self), который вызывается перед входом в цикл, чтобы инструкции были выведены.
Теперь добавим настоящую функциональность. Сначала реализуем добавление новых данных в телефонную книгу:
class PhoneBookApplication:
def __init__(self):
self.__phonebook = PhoneBook()
def help(self):
print("commands: ")
print("0 exit")
print("1 add entry")
def execute(self):
self.help()
while True:
print("")
command = input("command: ")
if command == "0":
break
elif command == "1":
name = input("name: ")
number = input("number: ")
self.__phonebook.add_number(name, number)
application = PhoneBookApplication()
application.execute()Если пользователь вводит 1 для добавления нового номера, пользовательский интерфейс спрашивает имя и номер и добавляет их в PhoneBook с помощью соответствующего метода, определенного в классе.
Единственная ответственность пользовательского интерфейса — общаться с пользователем. Любая другая функциональность, например хранение новой пары имя-номер, является ответственностью объекта PhoneBook.
В структуре нашего класса пользовательского интерфейса есть место для улучшения. Создадим метод add_entry(self), который обрабатывает команду добавления новой записи:
class PhoneBookApplication:
def __init__(self):
self.__phonebook = PhoneBook()
def help(self):
print("commands: ")
print("0 exit")
print("1 add entry")
# separation of concerns in action: a new method for adding an entry
def add_entry(self):
name = input("name: ")
number = input("number: ")
self.__phonebook.add_number(name, number)
def execute(self):
self.help()
while True:
print("")
command = input("command: ")
if command == "0":
break
elif command == "1":
self.add_entry()
application = PhoneBookApplication()
application.execute()Принцип разделения ответственности распространяется и на уровень методов. Можно было бы поместить всю функциональность пользовательского интерфейса в один сложный цикл while, но лучше отделить каждую функциональность в собственный метод. Ответственность метода execute() — только передавать команды, введенные пользователем, соответствующим методам. Это помогает управлять растуще й сложностью программы. Например, если позже мы захотим изменить способ добавления записей, сразу понятно, что усилия нужно сосредоточить на методе add_entry().
Добавим в пользовательский интерфейс функциональность поиска записей. Для нее тоже нужен отдельный метод:
class PhoneBookApplication:
def __init__(self):
self.__phonebook = PhoneBook()
def help(self):
print("commands: ")
print("0 exit")
print("1 add entry")
print("2 search")
def add_entry(self):
name = input("name: ")
number = input("number: ")
self.__phonebook.add_number(name, number)
def search(self):
name = input("name: ")
numbers = self.__phonebook.get_numbers(name)
if numbers == None:
print("number unknown")
return
for number in numbers:
print(number)
def execute(self):
self.help()
while True:
print("")
command = input("command: ")
if command == "0":
break
elif command == "1":
self.add_entry()
elif command == "2":
self.search()
else:
self.help()
application = PhoneBookApplication()
application.execute()Теперь у нас есть простое рабочее приложение телефонной книги, готовое к проверке. Ниже приведен пример запуска:
commands: 0 exit 1 add entry 2 search
command: 1 name: Eric number: 02-123456
command: 1 name: Eric number: 045-4356713
command: 2 name: Eric 02-123456 045-4356713
command: 2 name: Emily number unknown
command: 0
Для такого простого приложения мы написали довольно много кода. Если бы мы написали все внутри одного цикла while, вероятно, можно было бы обойтись гораздо меньшим количеством кода. Однако код довольно легко читать, структура ясна, и у нас не должно возникнуть трудностей с добавлением новых возможностей.
Шаг 3: импорт данных из файла
Предположим, у нас уже есть несколько телефонных номеров, сохраненных в файле, и мы хотим прочитать их при запуске программы. Файл данных имеет следующий формат CSV:
Eric;02-1234567;045-4356713
Emily;040-324344Работа с файлами явно является отдельной областью ответственности, поэтому она заслуживает собственного класса:
class FileHandler:
def __init__(self, filename):
self.__filename = filename
def load_file(self):
names = {}
with open(self.__filename) as f:
for line in f:
parts = line.strip().split(';')
name, *numbers = parts
names[name] = numbers
return namesМетод-конструктор принимает имя файла в качестве аргумента. Метод load_file(self) читает содержимое файла. Он разделяет каждую строку на две части: имя и списо к номеров. Затем он добавляет их в словарь, используя имя как ключ, а список как значение.
Метод использует удобную возможность Python: можно сначала выбрать некоторые элементы из списка отдельно, а затем взять оставшиеся элементы в новый список. Ниже показан пример. Возможно, вы помните из части 6, что строковый метод split возвращает список.
my_list = [1, 2, 3, 4, 5]
first, second, *rest = my_list
print(first)
print(second)
print(rest)1 2 [3, 4, 5]
Символ * перед именем переменной rest в инструкции присваивания означает, что эта последняя переменная должна содержать все оставшиеся элементы списка, начиная с третьего.
Обязательно нужно проверить обработчик файлов отдельно, прежде чем включать его в приложение:
t = FileHandler("phonebook.txt")
print(t.load_file()){'Eric': ['02-1234567', '045-4356713'], 'Emily': ['040-324344']}
Поскольку обработчик файлов, похоже, работает нормально, мы можем добавить его в приложение. Предположим, мы хотим читать файл первым делом при каждом запуске программы. Логичным местом для чтения файла будет конструктор класса PhoneBookApplication:
class PhoneBookApplication:
def __init__(self):
self.__phonebook = PhoneBook()
self.__filehandler = FileHandler("phonebook.txt")
# add the names and numbers from the file to the phone book
for name, numbers in self.__filehandler.load_file().items():
for number in numbers:
self.__phonebook.add_number(name, number)
# the rest of the programЭту функциональность тоже нужно проверить. Убедившись, что соде ржимое файла доступно через пользовательский интерфейс нашего приложения, можно переходить к следующему этапу.
Шаг 4: экспорт данных в файл
Последняя возможность в базовой версии приложения - сохранить содержимое телефонной книги обратно в тот же файл, из которого данные были прочитаны.
Для этого нужно изменить класс PhoneBook. Нам нужно уметь экспортировать содержимое телефонной книги:
class PhoneBook:
def __init__(self):
self.__persons = {}
# ...
# return all entries (in dictionary format)
def all_entries(self):
return self.__personsСамо сохранение в файл должен выполнять класс FileHandler. Добавим метод save_file, который принимает словарное представление телефонной книги в качестве аргумента:
class FileHandler():
def __init__(self, filename):
self.__filename = filename
def load_file(self):
# ...
def save_file(self, phonebook: dict):
with open(self.__filename, "w") as f:
for name, numbers in phonebook.items():
line = [name] + numbers
f.write(";".join(line) + "\n")Сохранение должно происходить при выходе из программы. Добавим для этого метод в пользовательский интерфейс и вызовем его перед выходом из цикла while:
class PhoneBookApplication:
# the rest of the code for the user interface
# a method which gets executed as the program exits
def exit(self):
self.__filehandler.save_file(self.__phonebook.all_entries())
def execute(self):
self.help()
while True:
print("")
command = input("command: ")
if command == "0":
self.exit()
break
elif command == "1":
self.add_entry()
elif command == "2":
self.search()
else:
self.help()Объекты в словаре
В следующем упражнении вам предлагается изменить телефонную книгу так, чтобы значениями в словаре были объекты, а не списки.
В этом нет ничег о принципиально странного, но это первый раз в курсе, когда предлагается нечто подобное, поэтому перед переходом к упражнению разберем более простой пример.
Здесь у нас есть приложение, которое отслеживает, сколько упражнений студенты выполнили на курсе. Количество упражнений каждого студента хранится в простом объекте:
class ExerciseCounter:
def __init__(self):
self.__exercises = 0
def done(self):
self.__exercises += 1
def how_many(self):
return self.__exercisesСледующая главная функция использует приведенный выше класс:
students = {}
print("let's do some exercises")
while True:
name = input("student: ")
if len(name) == 0:
break
# create a new object if it doesn't exist yet
if not name in students:
students[name] = ExerciseCounter()
# add a new done exercise to the counter
students[name].done()
print()
print("exercises completed:")
for student, exercises in students.items():
print(f"{student}'s exercises: {exercises.how_many()}")Запуск приведенного выше кода мог бы выглядеть так:
let's do some exercises student: peter student: sarah student: andy student: sarah student: charlotte student: charlotte student: andy student: sarah student:
exercises completed: peter's exercises: 1 andy's exercises: 2 sarah's exercises: 3 charlotte's exercises: 2
В примере выше есть несколько моментов, которые стоит рассмотреть. Когда пользовател ь вводит имя, программа сначала проверяет, является ли это имя уже ключом в словаре. Если имени нет, создается новый объект и добавляется как запись в словарь:
if not name in students:
students[name] = ExerciseCounter()После этого мы можем быть уверены, что объект существует и привязан к имени студента, которое используется как ключ. Либо он только что был создан, либо уже существовал с предыдущей итерации цикла. В любом случае теперь мы можем получить объект по ключу и вызвать метод done:
students[name].done()Строка выше на самом деле содержит два отдельных события. Можно было бы так же использовать вспомогательную переменную и записать это двумя отдельными строками кода:
students_counter = students[name]
students_counter.done()Обратите внимание: хотя объект здесь присваивается вспомогательной переменной, сам объект все еще существует в словаре, как и раньше. Вспомогательная переменная содержит ссылку на объект в словаре.
Если вы не совсем уверены, что именно происходит в коде выше, попробуйте выполнить его в инструменте визуализации.
Несколько заключительных замечаний
Структура примера PhoneBook выше довольно хорошо следует фундаментальным принципам объектно-ориентированного программирования. Главная идея — определить разные ответственности в программе и логически распределить их между разными классами и методами. Один из основных мотивов такого разделения — управление сложностью. Другой важный мотив состоит в том, что логическое разделение ответственностей — модульность, говоря профессиональным языком, — часто делает код проще в сопровождении и расширении.
В программных пакетах, которые разрабатываются и используются в широком мире, самая дорогая часть разработки — это сопровождение и расширение, то есть отладка существующего ПО и реализация новых возможностей. Правильно реализованная модульность финансово очень важна в разработке программного обеспечения.
Здесь стоит выделить еще несколько принципов объектно-ориентированного программирования. PhoneBook — хороший пример того, как ядро логики приложения можно (и нужно) отделить и от пользовательского интерфейса, и от любых средств хранения данных. Это важно по нескольким причинам. Во-первых, такое разделение позволяет тестировать код меньшими единицами, по одному классу и методу за раз. Во-вторых, поскольку ядро логики теперь независимо от интерфейсов с внешним миром, можно до некоторой степени менять реализацию либо ядра логики, либо интерфейсов, не ломая все приложение.
Процесс работы с файлами в приложении PhoneBook устроен так: программа читает файл один раз при запуске. После этого все данные хранятся в переменных внутри программы. При выходе программа снова сохраняет все данные, фактически перезаписывая файл. В большинстве случаев это рекомендуемый способ работы с внешними файлами, поскольку редактирование данных на месте часто гораздо сложнее.
Есть много хороших руководств по изучению хороших практик программирования. Одно из них — Clean Code Роберта Мартина. Однако примеры кода в книге реализованы на Java, поэтому разбор примеров на этом этапе вашей программистской карьеры может быть довольно трудоемким, хотя преподаватели курса очень рекомендуют саму книгу. Темы легко сопровожда емого, расширяемого и качественного кода будут дальше рассматриваться на курсах Software Development Methods и Software Engineering.
Написание кода в соответствии с принятыми принципами объектно-ориентированного программирования имеет свою цену. Скорее всего, вы напишете больше кода, чем если бы реализовали все одним непрерывным куском спагетти-кода. Один из ключевых навыков программиста — выбирать лучший подход для каждой ситуации. Иногда необходимо быстро собрать что-то для немедленного использования. С другой стороны, если в обозримом будущем можно ожидать, что код будет переиспользоваться, сопровождаться или дорабатываться — вами или, что еще важнее, кем-то совершенно другим, — читаемость и логическая модульность программного кода становятся обязательными. Чаще всего, если что-то стоит делать, это стоит делать хорошо, даже на самых ранних этапах разработки.
В завершение этой части материала вы реализуете еще одно более крупное приложение.
Эпилог
В завершение этой части материала на минуту вернемся к пользовательскому интерфейсу примера с телефонной книгой.
class PhoneBookApplication:
def __init__(self):
self.__phonebook = PhoneBook()
self.__filehandler = FileHandler("phonebook.txt")
# the rest of the program
application = PhoneBookApplication()
application.execute()Объект PhoneBookApplication содержит и объект PhoneBook, и объект FileHandler. Имя файла, передаваемое FileHandler, сейчас жестко прописано в классе PhoneBookApplication. Это совершенно несущественная деталь с точки зрения пользовательского интерфейса приложения. Более того, это нарушает принцип разделения ответственности: где объект PhoneBook сохраняет свое содержимое, не должно касаться PhoneBookApplication, но если бы мы захотели изменить расположение, нам пришлось бы менять код PhoneBookApplication.
Было бы лучше создать объект FileHandler где-нибудь вне класса PhoneBookApplication и передать его приложению в качестве аргумента:
class PhoneBookApplication:
def __init__(self, storage_service):
self.__phonebook = PhoneBook()
self.__storage_service = storage_service
# the rest of the user interface
# create a FileHandler
storage_service = FileHandler("phonebook.txt")
# pass it as an argument to PhoneBookApplication's constructor
application = PhoneBookApplication(storage_service)
application.execute()Это убирает ненужную зависимость из класса PhoneBookApplication. Если имя файла изменится, пользовательский интерфейс больше не нужно менять. Нужно лишь передать другой аргумент конструктору:
class PhoneBookApplication:
def __init__(self, filename):
self.__phonebook = PhoneBook()
self.__filename = filename
# the rest of the user interface
# use a different filename
storage_service = FileHandler("new_phonebook.txt")
application = PhoneBookApplication(storage_service)
application.execute()Это изменение также позволяет рассмотреть более экзотические места хранения, например облачный сервис в интернете. Нужно лишь реализовать класс, который использует облачный сервис и предлагает PhoneBookApplication ровно те же методы, что и FileHandler.
Экземпляр этого нового класса — "обработчика облака" — можно передать как аргумент конструктору, и в пользовательском интерфейсе не придется менять ни одной строки кода:
class CloudHandler:
# code for saving the contents of the phone book
# in a cloud service on the internet
storage_service = CloudHandler("amazon-cloud", "username", "passwrd")
application = PhoneBookApplication(storage_service)
application.execute()Как вы уже видели раньше, использование подобных техник имеет свою цену: нужно писать больше кода, поэтому программист должен оценить, является ли такой компромисс приемлемым.
Описанная выше техника называется внедрением зависимостей (dependency injection). Как следует из названия, идея в том, чтобы предоставлять любую зависимость, нужную объекту, извне объекта. Это очень полезный инструмент в арсенале программиста, потому что он упрощает реализацию новых возможностей в программах и облегчает автоматическое тестирование. Эта тема будет подробнее рассматр иваться на уже упомянутых курсах Software Development Methods и Software Engineering.
Ответьте, пожалуйста, на короткую анкету по этой части курса.
Текущие баллы можно посмотреть в синем индикаторе в правом нижнем углу страницы.