Ядро планеты Python
Обратите внимание, пока этот материал находится в статусе черновика. Чем дальше вы продвинетесь вглубь текста, тем больше неувязок, косноязычия и ошибок вы там увидите, а где-то примерно в районе середины руководства окончательно попадёте в область разрозненных заметок, невнятных почеркушек и псоголовых воинов, воюющих с драконами. Пожалуйста, не судите строго, материал хоть и небыстро, но упорно корректируется, медленно дрейфуя в сторону большей читаемости.
Этот материал со всеми свежими правками доступен на GitHub, вы вольны сколько угодно дополнять и переделывать его. Самое главное — учебник написан на Jupiter Notebook, а это значит, что вы можете интерактивно редактировать и исполнять код, мгновенно добавляя новые сущности или проясняя непонятные моменты.
.jpg)
Введение
Добрый день! Меня зовут Михаил Емельянов, по профессии я программист программ, а этот небольшой мини-учебник по базовым возможностям языка Python меня сподвиг написать довольно существенный, на мой взгляд, разрыв между декларируемыми объемами всевозможных курсов программирования и требованиями даже достаточно скромнооплачиваеых, но реальных вакансий.
Пользуясь аналогиями из игрового мира, можно сказать, что начинающий программист зачастую стоит на берегу озера кипящей лавы, в центре которого находится остров со столь вожделенными вакансиями, а промежуточные островки, по которым нужно ловко прыгать, постепенно наращивая свои навыки в последовательных мини-квестах, либо отсутствуют, либо расположены несистемно и хаотично, либо достаточно ровная их последовательность обрывается, так и не успев помочь отойти сколько-нибудь далеко от берега. Давайте попробуем построить дорожку островков-подсказок, ряд которых, хоть и не без усилий, позволит-таки нам достичь цели.
В этом руководстве с вероятностью 100 % есть ошибки и неточности самых разных калибров, так что, если что-то углядите, не стесняйтесь заводить PR и создавать форки на GitHub, любые предложения и дополнения бурно приветствуются; давайте вместе попробуем раскрыть специфику Python'а, удобство, красоту и силу этого прекрасного языка.
Разумеется, повествование может показаться вам несколько несбалансированным, т. к. я, увлёкшись, могу более подробно освещать темы, интересные лично мне, в ущерб целостности материала, и, с другой стороны, не хочу переписывать документацию на Python, нудно перечисляя методы работы со структурами данных. Кстати, не забывайте про великолепную официальную документацию docs.python.org. Она достаточно объёмна, но, изучив её, хотя бы «по диагонали», и постепенно углубляясь в нужные разделы, вы сможете убедиться, что многие «хаки», «открытия» и прочие не очевидные вещи уже давно разжеваны, описаны и имеют подробные примеры применения.
Здесь и далее вы можете увидеть вот такие вставки. Если это не эпиграф в начале главы, то это информационная вставка, содержащая ответ на вопрос собеседования. Например:
Что такое Python?
Интерпретируемый (преимущественно) язык программирования с динамической строгой типизацией и автоматическим управлением памятью.
Существуют реализации Python, позволяющие компилировать исходный код (например, cython), но, как правило, работа с Python строится при помощи интерпретатора.Такие вставки призваны закрепить материал.
Также я бы рекомендовал для изучения базового синтаксиса Python на полную катушку использовать leetcode.com. Если отфильтровать задачи по уровню «Easy», а потом добавить дополнительную сортировку по столбцу «Acceptance», то перед вами предстанет не волчий оскал соревновательной платформы, а ванильный букварь с плавно нарастающим уровнем задачек.
Что ж, пожалуй, довольно запрягать. Погнали!
Оглавление
Ниже вы видите оглавление, сделанное для лучшего усвоения не плоским, а в виде диаграммы-путеводителя.
Пользоваться путеводителем очень просто. Как в обычном тексте, идите слева направо и сверху вниз. Если вы только начинаете изучать Python, то идите по зеленым пунктам путеводителя. Если накопленный опыт, любопытство или необходимость толкают вас глубже, начните изучать разделы, помеченные серым. Оранжевым помечены темы, требующие углубленного изучения, ими лучше заняться (хотя бы и не копая, для начала, особенно глубоко) в третий проход. Даже если вы не собираетесь плотно использовать на практике какие-то из «оранжевых» тем, рассмотрите хотя бы их общие аспекты, на уровне чёткого понимания области применения, плюсов и минусов; держите, так сказать, в «горячем резерве».

1. Структуры данных
Как известно, программирование = структуры данных + алгоритмы (у Никлауса Вирта даже книга такая есть). Начнем с данных, а потом плавненько перейдем к методам их обработки.
Список (list)
Список — самая универсальная и популярная структура данных в Python. Если вы пока точно не определились, какая структура понадобится в вашем проекте, просто возьмите список, с него относительно просто мигрировать на что-нибудь более специализированное.
Список представляет собой упорядоченную изменяемую коллекцию объектов произвольного типа. Внутреннее строение списка — динамический массив указателей, т. е. внутри списки хранят не сами объекты, а ссылки на них, что позволяет им содержать элементы разных типов.
a = [] # Создаем пустой список
a: list[int] = [10, 20]
b: list[int] = [30, 40]
a.append(50) # Добавляем значение в конец списка
b.insert(2, 60) # Вставляем значение по определенному индексу
print(a, b)
a += b
print(f'Add: {a}')
a.reverse()
b = list(reversed(a)) # reversed() возвращает итератор, а не список
print(f'Reverse: {a}, {b}')
b = sorted(a) # Возвращает новый отсортированный список
a.sort() # Модифицирует исходный список и не возвращает ничего
print(f'Sort: {a}, {b}')
a.clear() # Очистка списка
[10, 20, 50] [30, 40, 60]
Add: [10, 20, 50, 30, 40, 60]
Reverse: [60, 40, 30, 50, 20, 10], [10, 20, 50, 30, 40, 60]
Sort: [10, 20, 30, 40, 50, 60], [10, 20, 30, 40, 50, 60]
s: str = 'A whole string'
list_of_chars: list = list(s)
print(list_of_chars)
list_of_words: list = s.split()
print(list_of_words)
i: int = list_of_chars.index('w') # Возвращает индекс первого вхождения искомого элемента или вызывает исключение ValueError
print(i)
list_of_chars.remove('w') # Удаляет первое вхождение искомого элемента или вызывает исключение ValueError
e = list_of_chars.pop(9) # Удаляет и возвращает значение, расположенное по индексу. pop() (без аргумента) удалит и вернет последний элемент списка
print(list_of_chars, e)
['A', ' ', 'w', 'h', 'o', 'l', 'e', ' ', 's', 't', 'r', 'i', 'n', 'g']
['A', 'whole', 'string']
2
['A', ' ', 'h', 'o', 'l', 'e', ' ', 's', 't', 'i', 'n', 'g'] r
Можно ли применять отрицательный индекс при работе с итеративными типами?
Да, можно. Отрицательный индекс позволяет вести отсчёт от конца структуры данных, например, массива или списка. Финт несколько неоднозначный, может привести к ошибкам, которые будет непросто найти, поэтому, например, в Golang подобный выверт запретили.
Строка (string)
Строки в Python 3 — иммутабельные последовательности, использующие кодировку Unicode.
se: str = '' # Пустая строка
si: str = str(12345) # Создает строку из числа
sj: str = ' '.join(['Follow', 'the', 'white', 'rabbit']) # Собирает строку из кусочков, используя указанный разделитель
print(f'Joined string: {sj}')
is_contains: bool = 'rabbit' in sj # Проверка наличия подстроки
is_startswith = sj.startswith('Foll')
is_endswith = sj.endswith('bat')
print(f'is_contains = {is_contains}, is_startswith = {is_startswith}, is_endswith = {is_endswith}')
sr: str = sj.replace('rabbit', 'sheep') # Замена подстроки. Можно указать количество замен: sr: str = sj.replace("rabbit", "sheep", times)
print(f'After replace: {sr}')
i1 = sr.find('rabbit') # Возвращает стартовый индекс первого вхождения или -1. Есть еще rfind(), начинающий искать с конца строки
i2 = sr.index('sheep') # Возвращает стартовый индекс первого вхождения или выкидывает ValueError. Есть еще rindex(), начинающий искать с конца строки
print(f"Start index of 'rabbit' is {i1}, start index of 'sheep' is {i2}")
d = str.maketrans({"a" : "x", "b" : "y", "c" : "z"})
st = "abc".translate(d)
print(f"Translate string: {st}")
sr = sj[::-1] # Реверс через slice с отрицательным шагом
print(f"Reverse string: {sr}")
Joined string: Follow the white rabbit
is_contains = True, is_startswith = True, is_endswith = False
After replace: Follow the white sheep
Start index of 'rabbit' is -1, start index of 'sheep' is 17
Translate string: xyz
Reverse string: tibbar etihw eht wolloF
Datetime
Для работы с датами и временем в datetime есть типы date, time, datetime и timedelta. Все они хэшируемы и иммутабельны.
from datetime import date, time, datetime, timedelta
d: date = date(year=1964, month=9, day=2)
t: time = time(hour=12, minute=30, second=0, microsecond=0, tzinfo=None, fold=0)
dt: datetime = datetime(year=1964, month=9, day=2, hour=10, minute=30, second=0)
td: timedelta = timedelta(weeks=1, days=1, hours=12, minutes=13, seconds=14)
print (f'{d}\n {t}\n {dt}\n {td}')
1964-09-02
12:30:00
1964-09-02 10:30:00
8 days, 12:13:14
Кортеж (tuple)
Кортеж — тоже список, только неизменяемый (immutable) и хэшируемый (hashable). Кортеж, содержащий те же данные, что и список, занимает меньше места и быстрее работает (разъяснение):
a = [2, 3, 'Boson', 'Higgs', 1.56e-22]
b = (2, 3, 'Boson', 'Higgs', 1.56e-22)
print(f'List: {a.__sizeof__()} bytes')
print(f'Tuple: {b.__sizeof__()} bytes')
List: 88 bytes
Tuple: 64 bytes
Разработчики Python непрерывно работают над оптимизацией внутренних структур хранения. Если вы запустите программу, показанную выше, в Python 3.13, то увидите результат "List: 88 bytes, Tuple: 64 bytes", но та же самая программа в Python 3.10 даст результат "List: 104 bytes".
Сам кортеж неизменяем, но если внутри кортежа находятся изменяемые элементы, например списки или словари, то их значения можно изменить.
Именованный кортеж (named tuple)
В полном соответствии с названием, имеет именованные поля. Удобно!
from collections import namedtuple
rectangle = namedtuple('rectangle', 'length width')
r = rectangle(length = 1, width = 2)
print(r)
print(r.length)
print(r.width)
print(r._fields)
rectangle(length=1, width=2)
1
2
('length', 'width')
Какая разница между списком и кортежем? Как в памяти хранятся списки и кортежи?
Список — изменяемая коллекция объектов произвольных типов. Внутреннее строение списка — динамический массив указателей.
Кортеж — тоже список, только неизменяемый. Кортеж, содержащий те же данные, что и список, занимает меньше места.
Словарь (dict)
Словарь — следующая по частоте использования структура данных в Python. Словарь — реализация хеш-таблицы, поэтому в качестве ключа нельзя брать нехэшируемый объект, например, список (тут-то нам и может пригодиться кортеж). Ключом словаря может быть любой неизменяемый объект: число, строка, datetime и даже функция. Такие объекты имеют метод __hash__(), который однозначно сопоставляет объект с некоторым числом. По этому числу словарь ищет значение для ключа.
Списки, словари и множества (которые мы рассмотрим чуть ниже) изменяемы и не имеют метода хеширования, при попытке подставить их в словарь возникнет ошибка.
d = {} # Создаем пустой словарь
d: dict[str, str] = {"Italy": "Pizza", "US": "Hot-Dog", "China": "Dim Sum"} # Непосредственное создание словаря
k = ["Italy", "US", "China"]
v = ["Pizza", "Hot-Dog", "Dim Sum"]
d = dict(zip(k, v)) # Создание словаря из двух коллекций при помощи zip
k = d.keys() # Коллекция ключей. Отражает изменения в основном словаре
v = d.values() # Коллекция значений. Тоже отражает изменения в основном словаре
k_v = d.items() # Кортежи ключ-значение, которые тоже отражают изменения в основном словаре
print(d)
print(k)
print(v)
print(k_v)
print(f"Mapping: {k.mapping['Italy']}")
d.update({"China": "Dumplings"}) # Добавление значение. При совпадении ключа старое значение будет перезаписано
print(f"Replace item: {d}")
c = d["China"] # Читаем значение
print(f"Read item: {c}")
try:
v = d.pop("Spain") # Удаляет значение или вызывает исключение KeyError
except KeyError:
print("Dictionary key doesn't exist")
# Примеры dict comprehension (более подробно comprehension будет рассмотрено позже)
b = {k: v for k, v in d.items() if "a" in k} # Вернет новый словарь, отфильтрованный по значению ключа
print(b)
c = {k: v for k, v in d.items() if len(v) >= 7} # Вернет новый словарь, отфильтрованный по длине значений
print(c)
d.clear() # Очистка словаря
{'Italy': 'Pizza', 'US': 'Hot-Dog', 'China': 'Dim Sum'}
dict_keys(['Italy', 'US', 'China'])
dict_values(['Pizza', 'Hot-Dog', 'Dim Sum'])
dict_items([('Italy', 'Pizza'), ('US', 'Hot-Dog'), ('China', 'Dim Sum')])
Mapping: Pizza
Replace item: {'Italy': 'Pizza', 'US': 'Hot-Dog', 'China': 'Dumplings'}
Read item: Dumplings
Dictionary key doesn't exist
{'Italy': 'Pizza', 'China': 'Dumplings'}
{'US': 'Hot-Dog', 'China': 'Dumplings'}
Что может быть ключом в словаре?
Ключом словаря может быть любой хэшируемый объект — число, строка, datetime или даже функция, т. е., объекты, имеющие метод __hash__, который однозначно сопоставляет объект с некоторым числом.
Решение проблемы вычисления хеша при работе со словарем
Любая хеш-таблица, в том числе и питоновский словарь, должна уметь решать проблему вычисления хеша. Для этого используются техники open addressing или chaining. Python использует open addressing.
Новый словарь инициализируется с 8 пустыми слотами.
Интерпретатор сначала пытается добавить новую запись по адресу, зависящему от хеша ключа.
addr = hash(key) & mask,
где
mask = PyDictMINSIZE - 1
Если этот адрес занят, то интерпретатор проверяет (при помощи ==) хеш и ключ. Если оба совпадают, то это означает, запись уже существует. Тогда начинается зондирование свободных слотов, которое идет в псевдослучайном порядке (порядок зависит от значения ключа). Новая запись будет добавлена по первому свободному адресу.
Чтение из словаря происходит аналогично, интерпретатор начинает поиск с позиции addr и идет по тому же псевдослучайному пути, пока не прочитает нужную запись.
Defaultdict
Если попытаться прочитать из обычного словаря значение ключа, которого там нет, то будет выброшено исключение KeyError (исключения будут рассмотрены ниже). Defaultdict позволяет не писать обработчик исключений, а просто воспринимает чтение несуществующего ключа как команду записать в этот ключ и вернуть значение по умолчанию; например, defaultdict(int) вернет 0, потому что значение по умолчанию для типа int равно 0.
from collections import defaultdict
dd = defaultdict(int)
print(dd[10]) # Печать int, будет выведен ноль, значение по умолчанию
dd = {} # "Обычный" пустой словарь
# print(dd[10]) # Вызовет исключение KeyError
0
Счетчик (counter)
Счетчик подсчитывает передаваемые ему объекты. Иногда очень удобно просто бухнуть в счетчик какой-нибудь список и сразу получить структуру данных с подсчитанными элементами.
from collections import Counter
shirts_colors = ["red", "white", "blue", "white", "white", "black", "black"]
c = Counter(shirts_colors)
print(c)
c["blue"] += 1
print(f"After shopping: {c}")
Counter({'white': 3, 'black': 2, 'red': 1, 'blue': 1})
After shopping: Counter({'white': 3, 'blue': 2, 'black': 2, 'red': 1})
Объяснение работы Counter() при помощи defaultdict():
from collections import defaultdict
shirts_colors = ["red", "white", "blue", "white", "white", "black", "black"]
d = defaultdict(int)
for shirt in shirts_colors:
d[shirt] += 1
print(d)
defaultdict(<class 'int'>, {'red': 1, 'white': 3, 'blue': 1, 'black': 2})
Множество (set)
Тоже очень распространённая питоновская структура данных. Когда-то, когда Python был молод, множества представляли собой несколько редуцированные словари, но со временем их судьбы (и реализации) стали расходиться. Однако, множество всё-таки является хеш-таблицей с соответствующим быстродействием на разных типах операций.
Множество, в отличие от, например, list, не поддерживает повторяющиеся элементы.
big_cities: set["str"] = {"New-York", "Los Angeles", "Ottawa"}
american_cities: set["str"] = {"Chicago", "New-York", "Los Angeles"}
big_cities |= {"Sydney"} # Добавить значение (или add())
american_cities |= {"Salt Lake City", "Seattle"} # Сложить множества (или update())
print(big_cities, american_cities)
union_cities: set["str"] = big_cities | american_cities # Или union()
intersected_cities: set["str"] = big_cities & american_cities # Или intersection()
dif_cities: set["str"] = big_cities - american_cities # Или difference()
symdif_cities: set["str"] = big_cities ^ american_cities # Или symmetric_difference()
issub: bool = big_cities <= union_cities # Или issubset()
issuper: bool = american_cities >= dif_cities # Или issuperset()
print(union_cities)
print(intersected_cities)
print(dif_cities)
print(symdif_cities)
print(issub, issuper)
big_cities.add("London")
big_cities.remove("Ottawa") # Удаляет значение, если оно имеется или выбрасывает KeyError
big_cities.discard("Los Angeles") # Удаляет значение без выбрасывания KeyError
big_cities.pop() # Возвращает и удаляет случайное значение (порядок в set не определен) или выбрасывает KeyError
big_cities.clear() # Очищает множество
{'New-York', 'Los Angeles', 'Sydney', 'Ottawa'} {'New-York', 'Seattle', 'Chicago', 'Los Angeles', 'Salt Lake City'}
{'Ottawa', 'Salt Lake City', 'Chicago', 'New-York', 'Seattle', 'Sydney', 'Los Angeles'}
{'New-York', 'Los Angeles'}
{'Ottawa', 'Sydney'}
{'Seattle', 'Ottawa', 'Chicago', 'Salt Lake City', 'Sydney'}
True False
Иммутабельное множество (frozenset)
Frozenset — тоже множество, только иммутабельное и хэшируемое. Напоминает разницу между списком и кортежем, не правда ли? Frozenset не имеет никаких преимуществ перед set ни по объёму занимаемой памяти, ни по скорости работы, хэшируемость (и как следствие - возможность применения в качестве ключа в словаре) - единственный плюс frozenset.
a = set({'New-York', 'Los Angeles', 'Ottawa'})
b = frozenset({'New-York', 'Los Angeles', 'Ottawa'})
print(f'Set: {a.__sizeof__()} bytes')
print(f'Frozenset: {b.__sizeof__()} bytes')
Set: 200 bytes
Frozenset: 200 bytes
Массив (array, bytes, bytearray)
Я перешел на Python с языков, более приближенных к «железу» (C, C#, даже на ассемблере когда-то программировал) и сначала немного удивлялся, что обычный массив, в котором всё так удобно лежит на своих местах, используется относительно редко. Массив в Python не является структурой данных, выбираемой по умолчанию, и используется только в случаях, когда решающую роль начинают играть размер структуры и скорость её обработки. Но, с другой стороны, если вы смотрите в сторону NumPy и Pandas (немного затронуты ниже), хотите быстро работать с данными, то массивы — ваше всё.
Массив хранит переменные только определенного типа, поэтому, в отличие от списка, не требует создания нового объекта для каждой новой переменной и выигрывает у списка в размерах и скорости доступа. Можно сказать, что это тонкая обёртка над Си-массивами.
Следует различать array («просто» массив, мутабелен), bytes (иммутабельный массив, содержащий только байты, наследие str из Python 2) и bytearray (мутабельный байтовый массив).
from array import array
a1 = array('l', [1, 2, 3, -4])
a2 = array('b', b'1234567890')
b = bytes(a2)
print(a1)
print(a2[0])
print(b)
print(a1.index(-4)) # Возвращает индекс элементы или выбрасывает ValueError
array('l', [1, 2, 3, -4])
49
b'1234567890'
3
# Создание иммутабельного массива
b1 = bytes([1, 2, 3, 4]) # Целые числа должны быть в диапазоне от 0 to 255
b2 = 'The String'.encode('utf-8')
b3 = (-1024).to_bytes(4, byteorder='big', signed=True) # byteorder = "big"/"little"/"sys.byteorder", signed = False/True
b4 = bytes.fromhex('FEADCA') # Для большей читаемости hex-значения могут быть разделены пробелами
b5 = bytes(range(10,30,2))
print(b1, b2, b3, b4, b5)
# Преобразование
c: list = list(b'\xfc\x00\x00\x00\x00\x01')
s: str = b'The String'.decode('utf-8')
b: int = int.from_bytes(b'\xfc\x00', byteorder='big', signed=False) # byteorder = big/little/sys.byteorder, signed = False/True
s2: str = b'\xfc\x00\x00\x00\x00\x01'.hex(' ')
print(c, s, b, s2)
with open('1.bin', 'wb') as file: # Байтовая запись в файл
file.write(b1)
with open('1.bin', 'rb') as file: # Чтение из файла
b6 = file.read()
print(b6)
b'\x01\x02\x03\x04' b'The String' b'\xff\xff\xfc\x00' b'\xfe\xad\xca' b'\n\x0c\x0e\x10\x12\x14\x16\x18\x1a\x1c'
[252, 0, 0, 0, 0, 1] The String 64512 fc 00 00 00 00 01
b'\x01\x02\x03\x04'
Какие типы данных есть в Python? Какие из них изменяемы, а какие нет?
str, bytes, int, float, complex, bool, None, tuple и frozenset — неизменяемы, list, set, dict, bytearray и memoryview — изменяемы.
Base64
Base64 — стандарт кодирования двоичных данных при помощи текстовых символов, конкретно, только при помощи 64 символов ASCII. Алфавит кодирования содержит латинские символы A-Z, a-z, цифры 0-9 (итого 62 знака) и 2 дополнительных символа, зависящих от системы реализации. Каждые 3 исходных байта кодируются четырьмя символами (увеличение объема данных на 33 %). Base64 помогает передавать двоичный данные при помощи текстовых сообщений, например, в электронных письмах или в JSON-файлах.
import base64
encoded = base64.b64encode(b'Hello, World!')
print(encoded)
data = base64.b64decode(encoded)
print(data)
b'SGVsbG8sIFdvcmxkIQ=='
b'Hello, World!'
Двусвязный список (deque)
Ссылки в каждом узле двусвязного списка указывают на предыдущий и на последующий узел в списке. Можно или использовать deque, или написать свою реализацию.
from collections import deque
d = deque([1, 2, 3, 4], maxlen=1000) # Лучше всегда сами выбирайте длину списка при помощи аргумента maxlen, это поможет избежать неприятных сюрпризов
d.append(5) # Добавить элемент в список справа
d.appendleft(0) # Добавить элемент в список слева
d.extend([6, 7]) # Расширить список справа
d.extendleft([-1, -2]) # Расширить список слева
print(d)
a = d.pop() # Вернуть и удалить элемент справа. Может выбросить IndexError
b = d.popleft() # Вернуть и удалить элемент слева. Может выбросить IndexError
print(a)
print(b)
print(d)
deque([-2, -1, 0, 1, 2, 3, 4, 5, 6, 7], maxlen=1000)
7
-2
deque([-1, 0, 1, 2, 3, 4, 5, 6], maxlen=1000)
Queue
Queue реализует FIFO со множественными поставщиками данных и множественными потребителями. Особенно полезен при многопоточности, позволяя корректно обмениваться информацией между потоками. Также существуют LifoQueue для реализации LIFO и PriorityQueue для реализации очереди с приоритетом.
from queue import Queue
q = Queue(maxsize=1000)
q.put('eat', block=True, timeout=10)
q.put('sleep') # По умолчанию block=True, timeout=None
q.put('code')
q.put_nowait('repeat') # Эквивалент put('repeat', block=False). Если свободный слот не будет предоставлен немедленно, будет выброшено исключение queue.Full
print(q.queue)
a = q.get(block=True, timeout=10) # Удалить и возвратить элемент из FIFO
b = q.get() # По умолчанию block=True, timeout=None
c = q.get_nowait() # Эквивалент get(False)
print(a, b, c, q.queue)
deque(['eat', 'sleep', 'code', 'repeat'])
eat sleep code deque(['repeat'])
Односвязный список
Односвязный список представляет набор связанных узлов, каждый из которых хранит собственные данные и ссылку на следующий узел. В практике применим редко, но его любят использовать интервьюеры на собеседованиях, чтобы кандидат мог блеснуть своими алгоритмическими познаниями. В Python встроенной реализации не имеет, можно или использовать deque (в основе которого лежит двусвязный список), или написать свою реализацию.
Граф
Граф как математическая абстракция есть совокупность двух множеств — множества самих объектов, называемого множеством вершин, и множества их парных связей, называемого множеством рёбер.
Матрица смежности (adjacency matrix)
Квадратная целочисленная матрица размера V*V, в которой значение элемента a{i, j} равно числу рёбер из i-й вершины в j-ю вершину.
Матрица смежности простого графа (не содержащего петель и кратных рёбер) является бинарной матрицей и содержит нули на главной диагонали.
Матрица инцидентности (incidence matrix)
Способ представления графа, в которой указываются связи между инцидентными элементами графа (ребрами и вершинами). Столбцы матрицы соответствуют ребрам, строки — вершинам. Ненулевое значение в ячейке матрицы указывает связь между вершиной и ребром (их инцидентность). Если связи между вершиной и ребром нет, то в соответствующую ячейку ставится «0».
В случае ориентированного графа каждой дуге ставится в соответствующем столбце: 1 в строке вершины x и -1 в строке вершины y.
Список смежности (adjacency list)
Самый распространенный формат хранения графа. Способ представления графа в виде коллекции списков вершин. Каждой вершине графа соответствует список, состоящий из «соседей» этой вершины.
Варианты:
• использование хеш-таблицы для ассоциации каждой вершины со списком смежных вершин;
• вершины представлены числовым индексом в массиве, каждая ячейка массива ссылается на однонаправленный связанный список соседних вершин;
• специальные классы вершин и рёбер, каждый объект вершины содержит ссылку на коллекцию рёбер, каждый объект ребра содержит ссылки на исходящую и входящую вершины.
Список инцидентности (incidence list)
Список инцидентности похож на список смежности, только с той разницей, что в i-ой строке записываются номера ребер, инцидентных данной i-ой вершине.
Сравнение структур представления графов
| Метод | Память | Добавить V | Добавить E | Удалить V | Удалить E | Проверка смежн. V |
|---|---|---|---|---|---|---|
| Матрица смежности (Adjacency matrix) |
V^2 | V^2 | 1 | V^2 | 1 | 1 |
| Матрица инцидентности (Incidence matrix) |
V*E | V*E | V*E | V*E | V*E | E |
| Список смежности (Adjacency list) |
V+E | 1 | 1 | V+E | E | V |
| Список инцидентности (Incidence list) |
V+E | 1 | 1 | E | E | E |
Дерево (tree)
Дерево — одна из наиболее распространённых структур данных, эмулирующая древовидную структуру в виде набора связанных узлов. Является связным графом, не содержащим циклы.
Бинарное дерево (binary tree)
Иерархическая структура данных, в которой каждый узел имеет не более двух потомков. Встроенной реализации не имеет, нужно писать свою. Как правило, используются деревья с дополнительными свойствами, рассмотренные ниже.
Куча (heap)
Бинарное дерево, удовлетворяющее свойство кучи: если B является узлом-потомком узла A, то ключ(A) ≥ ключ(B). Куча является максимально эффективной реализацией абстрактного типа данных, который называется очередью с приоритетом и поддерживающего две обязательные операции — добавить элемент и извлечь минимум (или максимум, в зависимости от реализации).
В Python min-куча (у которой наименьшее значение всегда лежит в корне) реализована на базе списка при помощи встроенного модуля heapq. Если вам нужна max-куча, с максимальным значением в корне, можете воспользоваться советами со Stackoverflow.
import heapq
h = [211, 1, 43, 79, 12, 5, -10, 0]
heapq.heapify(h) # Превращаем список в кучу
print(h)
heapq.heappush(h, 2) # Добавляем элемент
print(h)
m = heapq.heappop(h) # Извлекаем минимальный элемент
print(h, m)
[-10, 0, 5, 1, 12, 211, 43, 79]
[-10, 0, 5, 1, 12, 211, 43, 79, 2]
[0, 1, 5, 2, 12, 211, 43, 79] -10
Пробежимся коротенько по остальным структурам данных, которые в Python не имеют встроенной реализации, но, тем не менее, могут весьма пригодиться в реальном проекте.
Би-дерево (B-tree)
Сбалансированное дерево, оптимизированное для доступа к относительно медленным элементам памяти (например, дисковым структурам или индексам баз данных); как ветви, так и листья представляют собой списки (для того, чтобы можно было считать такой список в один проход для дальнейшего быстрого разбора в ОЗУ). Нужно писать свою реализацию. Либо — воспользоваться встроенной в Python поддержкой базы данных sqlite3, эта БД как раз реализована на би-дереве.
Красно-черное дерево (red-black tree)
Самобалансирующееся двоичное дерево поиска, позволяющее быстро выполнять основные операции: добавление, удаление и поиск узла. Сбалансированность достигается за счёт введения дополнительного признака узла дерева — «цвета». Этот атрибут может принимать одно из двух возможных значений — «чёрный» или «красный». Листовые узлы КЧ деревьев не содержат данных, поэтому не требуют выделения памяти — достаточно просто записать в узле-предке нулевой указатель на потомка.
Очень быстрая, полезная и практичная структура данных. Например, контейнер std::map в C++ реализован на базе красно-чёрного дерева.
Возможно, вы читали о том, что на серьёзных алгоритмических собеседовании в FAANG претендентов «заставляют крутить красно-черное дерево на доске». Это «кружение» и есть балансировка, после операции вставки или удаления элемента дерево нужно отбалансировать, с примерным объемом необходимого кода вы можете ознакомиться здесь или здесь.
АВЛ-дерево (AVL tree)
В АВЛ-деревьях операции вставки и удаления работают медленнее, чем в красно-черных деревьях (при том же количестве листьев красно-чёрное дерево может быть выше АВЛ-дерева, но не более чем в 1,388 раза). Поиск же в АВЛ-дереве выполняется быстрее (максимальная разница в скорости поиска составляет 39 %).
Префиксное дерево
Префиксное дерево (или trie) — структура данных, позволяющая хранить ассоциативный массив, ключами которого являются строки. Используется для алгоритмов типа T9, Ахо–Корасик или LZW.
Таблица выбора структуры данных
В квадратных скобках показан худший случай.
| Структура | Реализация | Применение | Индексация | Поиск | Вставка | Удаление | Память |
|---|---|---|---|---|---|---|---|
| Динамический массив | list | 1 | n | n | n | n | |
| Хэш-таблица | dict, set | 1 [n] |
1 [n] |
1 [n] |
n | ||
| Массив | array, bytes, bytearray | Для хранения однотипных данных | 1 | n | n | n | n |
| Односвязный список | - (~deque) | n | n | 1 | 1 | n | |
| Двусвязный список | deque | FIFO, LIFO | n | n | 1 | 1 | n |
| Бинарное дерево | - | logn [n] |
logn [n] |
logn [n] |
logn [n] |
n | |
| Куча | heapq | Очередь с приоритетом | 1 (find min) |
logn | logn (del min) |
n | |
| Би-дерево (B-tree) | ~sqlite | Память с медленным доступом | logn | logn | logn | logn | n |
| КЧ дерево | - | logn | logn | logn | logn | n | |
| АВЛ дерево | - | logn | logn | logn | logn | n | |
| Префиксное дерево (trie) | - | T9, Ахо–Корасик, LZW |
key | key | key |
Целочисленный диапазон (range)
range() возвращает иммутабельную последовательность чисел, которая часто используется как задатчик диапазона для цикла for.
r1: range = range(11) # Возвращает последовательность чисел от 0 до 10
r2: range = range(5, 21) # Возвращает последовательность чисел от 5 до 20
r3: range = range(20, 9, -2) # Возвращает последовательность чисел от 20 до 10 с шагом 2
print('To exclusive: ', end='')
for i in r1:
print(f"{i} ", end='')
print('\nFrom inclusive to exclusive: ', end='')
for i in r2:
print(f"{i} ", end="")
print('\nFrom inclusive to exclusive with step: ', end='')
for i in r3:
print(f'{i} ', end='')
print(f'\nFrom = {r3.start}')
print(f'To = {r3.stop}')
To exclusive: 0 1 2 3 4 5 6 7 8 9 10
From inclusive to exclusive: 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
From inclusive to exclusive with step: 20 18 16 14 12 10
From = 20
To = 9
Перечисление (Enum, IntEnum)
Удобные конструкции для определения заранее известных перечислений.
from enum import Enum, auto
import random
class Currency(Enum):
euro = 1
us_dollar = 2
yuan = auto()
local_currency = Currency.us_dollar
print(local_currency)
local_currency = Currency['yuan'] # Может вызвать исключение KeyError
print(local_currency)
local_currency = Currency(1) # Может вызвать исключение ValueError
print(local_currency)
print(local_currency.name)
print(local_currency.value)
list_of_members = list(Currency)
member_names = [e.name for e in Currency]
member_values = [e.value for e in Currency]
random_member = random.choice(list(Currency))
print(list_of_members, '\n',
member_names, '\n',
member_values, '\n',
random_member)
Currency.us_dollar
Currency.yuan
Currency.euro
euro
1
[<Currency.euro: 1>, <Currency.us_dollar: 2>, <Currency.yuan: 3>]
['euro', 'us_dollar', 'yuan']
[1, 2, 3]
Currency.euro
Классы данных (dataclass)
Dataclass — декоратор (подробнее про декораторы мы поговорим позже), автоматически создающий методы init(), repr() и eq(). Нужен для создания классов, главной задачей которых является хранение данных. Аннотации типов обязательны.
Существует более продвинутая альтернатива под названием attrs, но Глиф Лефковиц дополнил свою статью «The One Python Library Everyone Needs», написанную в 2016, замечанием о том, что за прошедшие годы dataclass значительно возмужал (во многом благодаря влиянию attrs) и теперь вполне можно обойтись без attrs, просто используя dataclass.
from dataclasses import dataclass
from decimal import *
from datetime import datetime
@dataclass
class Transaction:
value: Decimal
issuer: str = 'Default Bank'
dt: datetime = datetime.now()
t1 = Transaction(value=1000_000, issuer='Deutsche Bank', dt = datetime(2025, 1, 1, 12))
t2 = Transaction(1000)
print(t1)
print(t2)
Transaction(value=1000000, issuer='Deutsche Bank', dt=datetime.datetime(2025, 1, 1, 12, 0))
Transaction(value=1000, issuer='Default Bank', dt=datetime.datetime(2025, 6, 5, 13, 35, 57, 183323))
Dataclass может быть сделан иммутабельным при помощи директивы frozen=True.
from dataclasses import dataclass
@dataclass(frozen=True)
class User:
name: str
account: int
Бинарная запаковка (struct)
Запаковка (и распаковка, разумеется) данных в байтовые последовательности с предопределенными размерами каждого элемента данных, их порядка в структуре, а также порядка байт для многобайтовых типов данных. Нужна для взаимодействия Python-программы с кодом на C или C++ и позволяет превращать Python-овский int в, например, «сишный» short int или long int (подробности про систему типов языка Си).
При работе со структурами вам нужно будет ориентироваться в том, что такое little-endian и big-endian, а также не забывать, что размер типа данных в Си бывает разным.
from struct import pack, unpack, iter_unpack
b = pack('>hhll', 1, 2, 3, 4)
print(b)
t = unpack('>hhll', b)
print(t)
i = pack('ii', 1, 2) * 5
print(i)
print(list(iter_unpack('ii', i)))
b'\x00\x01\x00\x02\x00\x00\x00\x03\x00\x00\x00\x04'
(1, 2, 3, 4)
b'\x01\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\x00\x00\x00'
[(1, 2), (1, 2), (1, 2), (1, 2), (1, 2)]
memoryview
Memoryview — механизм, позволяющий получить доступ к данным объектов без предварительного копирования. Memoryview позволяет работать не со всеми объектами, а только с теми, которые поддерживают buffer protocol (мы познакомимся с ним сильно позже, в последней главе, посвященной взаимодействию Python и C кода); к таковым относятся bytes, bytearray, memoryview (да, можно взять memoryview от memoryview), массивы NumPy и изображения в библиотеке Pillow.
У Memoryview есть несколько основных применений:
обработка больших массивов информации;
быстрая обработка данных;
манипуляция данными на низком уровне.
Подробнее про memoryview можно почитать вот здесь.
2. Обработка данных
Логические операторы
В Python есть три простых логических оператора — not (инверсия), and (логическое «И») и or (логическое «ИЛИ»). Операторы работают с переменными типа bool.
a: bool = 15 > 10
b: bool = 20 < 5
print(a)
print(b)
print ('not:', not a)
print ('and:', a and b)
print ('or:', a or b)
True
False
not: False
and: False
or: True
В использовании логических операторов есть два небольших нюанса.
Во-первых, у логических операторов тоже есть приоритет исполнения — наивысший приоритет у not, потом идёт and, потом or. Так что при необходимости используйте скобки.
Во-вторых, вы можете подкинуть логическим операторам другой тип данных, не bool, но тогда его поведение будет несколько странным, если заранее не знать об этой особенности. Дело в том, что логические операторы возвращают первое встреченное значение, влияющее на всю цепочку вычислений. Это True для or и False для and:
print(10 or 20 or 30)
print(0 and 2 and 3)
10
0
Если хотите избавиться от этой особенности, просто прямо приведите тип вычисляемого выражения к bool:
print(bool(10 or 20 or 30))
print(bool(0 and 2 and 3))
True
False
Битовые операции
Чтобы научиться работать с битовыми операциями, нужно представлять, как то или иное число выглядит в двоичной системе счисления. Вспомним, как выглядят в двоичном виде числа от 0 до 15:
for a in range(0, 16):
print(f"{a:#02d}: {a:#06b}")
00: 0b0000
01: 0b0001
02: 0b0010
03: 0b0011
04: 0b0100
05: 0b0101
06: 0b0110
07: 0b0111
08: 0b1000
09: 0b1001
10: 0b1010
11: 0b1011
12: 0b1100
13: 0b1101
14: 0b1110
15: 0b1111
Вот с этими-то единицами и нулями, отсылающими нас к реальному формату хранения данных в компьютерных системах, и работают битовые операции. Если вам прежде не приходилось работать с двоичным форматом, лучше немного попрактикуйтесь с ним; заодно изучите шестнадцатеричный формат, в котором четыре двоичных цифры представлены одним знаком от 0 до F:
for a in range(0, 16):
print(f"{a:#06b}: {a:#03x}")
0b0000: 0x0
0b0001: 0x1
0b0010: 0x2
0b0011: 0x3
0b0100: 0x4
0b0101: 0x5
0b0110: 0x6
0b0111: 0x7
0b1000: 0x8
0b1001: 0x9
0b1010: 0xa
0b1011: 0xb
0b1100: 0xc
0b1101: 0xd
0b1110: 0xe
0b1111: 0xf
Битовые операции подразделяются на:
~ (NOT);
& (AND);
| (OR);
^ (XOR);
<<, >> (сдвиги влево и вправо).
Если вы уже поняли формат представления чисел в двоичной системе счисления, то и понимание логики работы этих операторов не вызовет у вас никаких затруднений. Есть, однако, даже в таких тихих водах несколько подводных камней, которые мы с вами тоже разберём.
~ (NOT) побитово инвертирует все единички и нолики:
a: int = 8
b = ~a
print(f"{a:#06b}: {b:#06b}")
0b1000: -0b1001
Эм, это не совсем то, что мы ожидали... Инверсия от 0b1000 вроде же должна выглядеть как 0b0111? Дело в том, что int в Python - знаковый. Операция "~" действительно инвертирует все биты числа, но затрагивает еще и знаковый бит, поэтому результат получается несколько неожиданным.
Что делать? Выход есть - нужно взять беззнаковые числа из numpy, работа с ними будет более предсказуемой:
import numpy as np
a = np.uint8(0b10001100) # Берём беззнаковое число из numpy
b = ~a
print(f"{a:#b}: {b:#b}")
0b10001100: 0b1110011
Таблица истинности оператора & (AND) такова:
1 & 1 = 1
1 & 0 = 0
0 & 1 = 0
0 & 0 = 0
Как видите, единичка на выходе разряда будет только тогда, когда на входе две единички.
a: int = 0b1101
b: int = 0b1000
c = a & b
print(f"{c:#06b}")
0b1000
У оператора | (OR) такая таблица истинности: 1 | 1 = 1 1 | 0 = 1 0 | 1 = 1 0 | 0 = 0
a: int = 0b1001
b: int = 0b0010
c = a | b
print(f"{c:#06b}")
0b1011
Оператор ^ (XOR) называется также "Исключающим ИЛИ", у него такая таблица истинности: 1 ^ 1 = 0 1 ^ 0 = 1 0 ^ 1 = 1 0 ^ 0 = 0
Как видите, единичка на выходе появляется только тогда, когда на входе разные переменные, для двух одинаковых переменных будет выдан ноль.
a: int = 0b11111100
b: int = 0b00111111
c = a ^ b
print(f"{c:#010b}")
0b11000011
Наконец, сдвиги:
a = 0b1100
b = a << 1
c = a >> 1
print(f"{b:#b}: {c:#b}")
0b11000: 0b110
Когда-то сдвиги были популярным методом ускорения умножения или деления числа на два (если вы сдвинете влево, например, 16, то получите 32), но современные компиляторы и интерпретаторы, как правило, не нуждаются в такого рода подсказках.
Подсчет битов
a: int = 4242
print(f"{a} in binary format: 0b{a:b}")
c = a.bit_count() # Возвращает количество "единичек" в двоичном представлении числа
print(f"Bit count: {c}")
4242 in binary format: 0b1000010010010
Bit count: 4
Простейшие вычисления — Sum, Count, Min, Max
a: list[int] = [1, 2, 3, 4, 5, 2, 2]
s = sum(a)
print(s)
c = a.count(2) # Вернет количество вхождений
print(c)
mn = min(a)
print(mn)
mx = max(a)
print(mx)
19
3
1
5
Присмотритесь к встроенным функциям, там есть ещё кое-что, касающееся элементарной математики.
Базовая математика
from math import pi
a: float = pi ** 2 # Or pow(pi, 2)
print(f"Power: {a}")
b: float = round(pi, 2)
print(f"Round: {b}")
c: int = round(256, -2)
print(f"Int round: {c}")
d: float = abs(-pi)
print(f"Abs: {d}")
e: float = abs(10+10j) # Or e: float = abs(complex(real=10, imag=10))
print(f"Complex abs: {e}")
Power: 9.869604401089358
Round: 3.14
Int round: 300
Abs: 3.141592653589793
Complex abs: 14.142135623730951
Fractions
Обеспечивает работу с рациональными числами, т. е. с числами, представимыми в виде дроби.
from fractions import Fraction
f = Fraction("0.2").as_integer_ratio()
print(f)
(1, 5)
Евклидово расстояние между двумя точками
import math
p1 = (0.22, 1, 12)
p2 = (-0.12, 3, 7)
print(math.dist(p1, p2))
5.39588732276722
Сортировка (sort, sorted)
В сортировке всё самое интересное спрятано под капотом (мы ненадолго вернемся к этой теме чуть ниже, в разделе «Алгоритмы»), пока рассмотрим только Python-специфичный синтаксис.
Надо различать методы sort() и sorted(), первый сортирует данные in-place, второй порождает новую структуру.
a: list = [5, 2, 3, 1, 4]
b: list = sorted(a)
print(a, b)
a.sort()
print(a)
[5, 2, 3, 1, 4] [1, 2, 3, 4, 5]
[1, 2, 3, 4, 5]
И sort(), и sorted() имеют параметр key для указания функции, которая будет вызываться на каждом элементе. Если вам больше по нраву сортировка при помощи функции, принимающей два аргумента (или вы привыкли к cmp в Python 2), присмотритесь к functools.cmp_to_key().
# Регистрозависимое сравнение строк
dinos: str = "Dinosaurs were Big and small"
a = sorted(dinos.split())
print(a)
# Регистронезависимое сравнение строк
dinos: str = "Dinosaurs were Big and small"
b = sorted(dinos.split(), key=str.lower)
print(b)
['Big', 'Dinosaurs', 'and', 'small', 'were']
['and', 'Big', 'Dinosaurs', 'small', 'were']
Сложносочиненные структуры данных можно сортировать при помощи лямбд (что такое лямбды, будет рассмотрено ниже) по key=lambda el: el[1] или даже, например по key=lambda el: (el[1], el[0]).
Comprehension
Comprehension, которое переводится то как включение в список, то как абстракция списков (Википедия), то вообще никак не переводится — способ компактного описания операций обработки списков (а применительно к Python — еще и словарей, и множеств). Некоторые авторы используют термин «генераторы списков», но он пересекается с собственно генераторами — объектами, использующими отложенные вычисления, что вносит еще большую путаницу.
Проще говоря, если вам нужно получить из некоторой структуры данных (например, из другого списка) список, включающий только те значения, которые удовлетворяют какому-то определенному условию, или вычисляемые из первого списка по каким-то определенным правилам, то comprehension — претендент на решение этой задачи № 1.
# Примеры Comprehension
a = [i+1 for i in range(10)] # list
b = {i for i in range(10) if i > 5} # set
c = (2*i+5 for i in range(10)) # iter
d = {i: i**2 for i in range(10)} # dict
print(a,"\n", b, "\n", list(c), "\n", d)
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
{8, 9, 6, 7}
[5, 7, 9, 11, 13, 15, 17, 19, 21, 23]
{0: 0, 1: 1, 2: 4, 3: 9, 4: 16, 5: 25, 6: 36, 7: 49, 8: 64, 9: 81}
Тут главное не перегнуть палку. Если запись comprehension становится слишком сложной и нечитаемой, возможно, стоит развернуть логику в «нормальный» цикл или в другой более удобочитаемый алгоритм. Comprehension соблазняет записывать «однострочниками» достаточно сложные выражения, но не забывайте, что программист примерно 90 % времени читает код, и только 10 % пишет, так что если выражение будет плохочитаемым, вы усложните жизнь и себе, и свои коллегам.
Есть более-менее удачные «однострочники», есть быстрые, но плохочитаемые, написанные из спортивного интереса (это ссылки на решенные мной задачки на leetcode), желательно использовать comprehension в меру; лучше написать понятный развернутый алгоритм, чем непонятный, но обложенный пояснениями (если нет особых требований к производительности, само собой).
Еще немного про list comprehension:
# new_list = [expression for member in iterable (if conditional)]
fruits: list = ["Lemon", "Apple", "Banana", "Kiwi", "Watermelon", "Pear"]
e_fruits = [fruit for fruit in fruits if "e" in fruit]
# ☝ условие
print(e_fruits)
upper_fruits = [fruit.upper() for fruit in fruits]
# ☝ выражение
print(upper_fruits)
# Пример разбиения списка на фрагменты одинаковой длины
chunk_len = 2
chunk_fruits = [fruits[i:i + chunk_len] for i in range(0, len(fruits), chunk_len)]
print(chunk_fruits)
['Lemon', 'Apple', 'Watermelon', 'Pear']
['LEMON', 'APPLE', 'BANANA', 'KIWI', 'WATERMELON', 'PEAR']
[['Lemon', 'Apple'], ['Banana', 'Kiwi'], ['Watermelon', 'Pear']]
Что такое list comprehension?
List comprehension (или включение в список) — компактный способ формирования списков из других структур данных, позволяющий отфильтровать значения или провести над ними вычисления. Включение в список эквивалентно циклу for, но обладает более удобоваримой записью.
Dict comprehension, включение в словарь:
# new_dict = {expression for member in iterable (if conditional)}
d: dict = {"Italy": "Pizza", "US": "Hot-Dog", "China": "Dim Sum", "South Korea": "Kimchi"}
print(d)
a: dict = {k: v for k, v in d.items() if "i" in v} # Вернет новый словарь, отфильтрованный по значению
print(a)
b: dict = {k: v for k, v in d.items() if "i" in k} # Вернет новый словарь, отфильтрованный по ключу
print(b)
c: dict = {k: v for k, v in d.items() if len(v) >= 7} # Вернет новый словарь, отфильтрованный по длине значений
print(c)
{'Italy': 'Pizza', 'US': 'Hot-Dog', 'China': 'Dim Sum', 'South Korea': 'Kimchi'}
{'Italy': 'Pizza', 'China': 'Dim Sum', 'South Korea': 'Kimchi'}
{'China': 'Dim Sum'}
{'US': 'Hot-Dog', 'China': 'Dim Sum'}
Попробуйте самостоятельно поиграться с set comprehension. Не забывайте, что set «переваривает» только уникальные значения, поэтому в результате вы можете получить не совсем то, на что рассчитывали.
Попробуйте также освоить nested (вложенный) comprehension, используя конструкции вида [[func(y) for y in x] for x in n]. Для примера создайте двумерный массив, содержащий случайные значения, среднее значение которых плавно нарастает ближе к правому нижнему углу (если не получится, готовый пример есть чуть ниже, в коде, иллюстрирующем применение matplotlib).
Срез (slice)
Самый простой метод обработки данных, просто возвращает ту часть данных, местоположение которой (индексы) удовлетворяет определенным условиям.
a:str = "Pack my box with five dozen liquor jugs"
start, stop = 8, 21
b:str = a[start:stop] # Значения от start до stop-1
c:str = a[start:] # Значения от start до конца структуры
d:str = a[:stop] # Значения от начала до stop-1
e:str = a[:] # Полная копия структуры
print(b, "\n",
c, "\n",
d, "\n",
e, "\n")
box with five
box with five dozen liquor jugs
Pack my box with five
Pack my box with five dozen liquor jugs
Значения start и stop могут быть отрицательными, это будет означать, что отсчет ведется от конца структуры. Будьте аккуратны, используя отрицательные значениями индекса можно наделать еще больше ошибок, чем обычно; например, разработчики языка Go намеренно отказались от этой возможности.
Можно также использовать значение step, чтобы на выход среза попали не все подряд данные из входной структуры.
a:str = "Step on no pets"
b:str = a[-4:] # "Хвостик"
c:str = a[::-1] # Реверс входной строки
d:str = a[4::-1] # Первые четыре значения, реверсированы
e:str = a[::2] # Каждый второй символ
print(b, "\n",
c, "\n",
d, "\n",
e, "\n")
pets
step on no petS
petS
Se nn es
bisect(), бинарный поиск
Бинарный поиск существенно быстрее, чем обычный (см. раздел «Алгоритмы»), но работает только с отсортированными коллекциями.
Есть мнение, что бинарный поиск со всеми его особенностями и краевыми случаями — идеальная основа для собеседования, алгоритм, который при постепенном введении дополнительных условий хорошо отражает уровень подготовки кандидата. Так что присмотритесь к bisect и его конкретным реализациям повнимательнее.
import bisect
a: list[int] = [12, 6, 8, 19, 1, 33]
a.sort()
print(f"Sorted: {a}")
print(bisect.bisect(a, 20)) # Найти индекс для потенциальной вставки
bisect.insort(a, 15) # Вставка значения в отсортированную последовательность
print(a)
# Бинарный поиск
def binary_search(a, x, lo=0, hi=None):
if hi is None:
hi = len(a)
pos = bisect.bisect_left(a, x, lo, hi)
return pos if pos != hi and a[pos] == x else -1
print(binary_search(a, 15))
Sorted: [1, 6, 8, 12, 19, 33]
5
[1, 6, 8, 12, 15, 19, 33]
4
Операции над строками. lower(), upper(), capitalize() и title()
s: str = "camelCase string"
print(s.lower())
print(s.upper())
print(s.capitalize())
print(s.title())
camelcase string
CAMELCASE STRING
Camelcase string
Camelcase String
strip(), lstrip(), rstrip()
Обрезает начальные и конечные символы в строке.
s: str = " ~~##A big blahblahblah##~~ "
s = s.strip() # strip() без аргумента удалит начальные и конечные пробелы и символы табуляции.
print(s)
s = s.strip("~#") # Удалит переданные символы в начале и в конце строки
print(s)
s = s.lstrip(" A") # Удалит переданные символы слева
print(s)
s = s.rstrip("habl") # Удалит переданные символы справа
print(s)
~~##A big blahblahblah##~~
A big blahblahblah
big blahblahblah
big
split(), splitlines(), rsplit()
Разделяет строку на подстроки.
s1: str = "Follow the white rabbit, Neo"
c1 = s1.split() # split() без аргумента использует в качестве разделителей пробелы и символы табуляции
print(c1)
c2 = s1.split(sep=", ", maxsplit=1) # В качестве разделителя будет использоваться строка ", ". Дополнительный параметр maxsplit позволяет ограничить число разделений
print(c2)
s2: str = "Beware the Jabberwock, my son!\n The jaws that bite, the claws that catch!"
c3 = s2.splitlines(keepends=False) # При keepends=False символы разделения строк (\n\r\f\v\x1c-\x1e\x85\u2028\u2029 и \r\n) будт исключены из результирующих строк
print(c3)
# split() vs rsplit()
c4 = s2.split(maxsplit=2)
c5 = s2.rsplit(maxsplit=2)
print(c4, c5)
['Follow', 'the', 'white', 'rabbit,', 'Neo']
['Follow the white rabbit', 'Neo']
['Beware the Jabberwock, my son!', ' The jaws that bite, the claws that catch!']
['Beware', 'the', 'Jabberwock, my son!\n The jaws that bite, the claws that catch!'] ['Beware the Jabberwock, my son!\n The jaws that bite, the claws', 'that', 'catch!']
ord(), chr()
Преобразование между символом Unicode и его целочисленным значением.
s1: str = "abcABC!"
for ch in s1:
print(f"{ch} -> {ord(ch)}") # Возвращает целочисленное значение символа Unicode
nums = [72, 101, 108, 108, 111, 33]
for num in nums:
print(f"{num} -> {chr(num)}") # Возвращает символ Unicode
a -> 97
b -> 98
c -> 99
A -> 65
B -> 66
C -> 67
! -> 33
72 -> H
101 -> e
108 -> l
108 -> l
111 -> o
33 -> !
Regex
Регулярные выражения — отдельная область знаний, и весьма-весьма непростая область. Тут, пожалуй, самое время для бородатой шутки про то, что если вы решили свою проблему при помощи регулярных выражений — теперь у вас две проблемы :)
Регулярки похожи на вхождение в воду на пляже острова Гуам в сторону Марианской впадины — даже когда вы думаете, что погрузились реально глубоко, то, скорее всего, вы просто не видите бездны, лежащей впереди. Но — знать регулярные выражения, хотя бы на начальном уровне, необходимо для решения целого класса задач, а то, что вёрткие регулярки периодически поворачиваются к вам своими, кхм... новыми гранями, придется простить, переварить и принять.
Надо заметить, что иногда нелегко не только составить нужную регулярку, но и по прошествии времени вносить в неё минимальные изменения, т. к. для того, чтобы внести достаточно небольшие коррективы в регулярное выражение, необходимо порой заново «переварить» его, упорно преодолевая слабую человекочитаемость.
Вот здесь есть грамотное и методически выдержанное введение в тему, пока же окинем взглядом основные возможности регулярных выражений:
import re
s1: str = "123 abc ABC 456"
m1 = re.search("[aA]", s1) # Ищет первое вхождение паттерна, при неудаче возвращает None
print(m1, m1.group(0))
m2 = re.fullmatch("[aA]", s1) # Проверка, подходит ли строка под шаблон
print(m2)
c1: list = re.findall("[aA]", s1) # Найти в строке все непересекающиеся шаблоны
print(c1)
def replacer(s):
return chr(ord(s[0]) + 1) # Следующий символ из алфавита
s2 = re.sub("\w", replacer, s1) # Вы можете использовать функцию вместо шаблона
print(s2)
c2 = re.split("\d", s1)
print(c2)
iter = re.finditer("\D", s1) # Итератор по непересекающимся шаблонам
for ch in iter:
print(ch.group(0), end= "")
<re.Match object; span=(4, 5), match='a'> a
None
['a', 'A']
234 bcd BCD 567
['', '', '', ' abc ABC ', '', '', '']
abc ABC
Match Object
import re
m3 = re.match(r"(\w+) (\w+)", "John Connor, leader of the Resistance")
s3: str = m3.group(0) # Возвращает полное совпадение
s4: str = m3.group(1) # Возвращает часть в первых скобках
t1: tuple = m3.groups()
start: int = m3.start() # Возвращает начальный индекс совпадения
end: int = m3.end() # Возвращает конечный индекс совпадения
t2: tuple[int, int] = m3.span() # Кортеж (start, end)
print (f"{s3}\n {s4}\n {t1}\n {start}\n {end}\n {t2}\n")
John Connor
John
('John', 'Connor')
0
11
(0, 11)
Split
Разбивка строки с использованием регулярного выражения.
import re
ip = '192.168.0.1:8080'
split_ip_1 = re.split(r'[.:]', ip)
split_ip_2 = [j for i in ip.split(':') for j in i.split('.')] # Эту же задачу можно решить без regex, но придётся использовать list comprehension
print(split_ip_1, split_ip_2)
['192', '168', '0', '1', '8080'] ['192', '168', '0', '1', '8080']
Compile
re.compile используется при работе с одним и тем же регулярным выражением. В этом случае Python создаёт объект, который можно использовать повторно и сэкономить ресурсы.
import re
result = re.match('hell', 'hello world')
print(result)
pattern = re.compile('hell')
result = pattern.match('hello world')
print(result)
<re.Match object; span=(0, 4), match='hell'>
<re.Match object; span=(0, 4), match='hell'>
Finditer
В отличие от findall, finditer выдает не список, а итератор, что может быть полезно при большом объёме выдаваемых объектов.
Создание переменных datetime
Python использует Unix Epoch: "1970-01-01 00:00 UTC"
from datetime import datetime
from dateutil.tz import tzlocal
dt1: datetime = datetime.fromisoformat("2021-10-04 00:05:23.555+00:00") # Может вызвать ValueError
dt2: datetime = datetime.strptime("21/10/04 17:30", "%d/%m/%y %H:%M") # Подробнее про форматы — https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes
dt3: datetime = datetime.fromordinal(100_000) # 100000-й день от 1.1.0001
dt4: datetime = datetime.fromtimestamp(20_000_000.01) # Время в секундах с начала Unix Epoch
tz = tzlocal()
dt5: datetime = datetime.fromtimestamp(20_000_000.01, tz) # С учетом часового пояса
print (f"{dt1}\n {dt2}\n {dt3}\n {dt4}\n {dt5}")
2021-10-04 00:05:23.555000+00:00
2004-10-21 17:30:00
0274-10-16 00:00:00
1970-08-20 16:33:20.010000
1970-08-20 16:33:20.010000+05:00
Преобразование переменных datetime
from datetime import datetime
dt1: datetime = datetime.today()
s1: str = dt1.isoformat()
s2: str = dt1.strftime("%d/%m/%y %H:%M") # https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes
i: int = dt1.toordinal()
a: float = dt1.timestamp() # Секунды с начала Unix Epoch
print (f"{dt1}\n {s1}\n {s2}\n {i}\n {a}")
2022-09-06 17:50:38.041159
2022-09-06T17:50:38.041159
06/09/22 17:50
738404
1662468638.041159
Арифметика datetime
from datetime import date, time, datetime, timedelta
from dateutil.tz import UTC, tzlocal, gettz, datetime_exists, resolve_imaginary
d: date = date.today()
dt1: datetime = datetime.today()
dt2: datetime = datetime(year=1981, month=12, day=2)
td1: timedelta = timedelta(days=5)
td2: timedelta = timedelta(days=1)
d = d + td1 # date = date ± timedelta
dt3 = dt1 - td1 # datetime = datetime ± timedelta
td3 = dt1 - dt2 # timedelta = datetime - datetime
td4 = 10 * td1 # timedelta = const * timedelta
c: float = td1/td2 # timedelta/timedelta
print (f"{d}\n {dt3}\n {td3}\n {td4}\n {c}")
2022-09-11
2022-09-01 17:50:38.132916
14888 days, 17:50:38.132916
50 days, 0:00:00
5.0
Today, now
Получение текущей даты или даты/времени.
from datetime import date, datetime
import pytz # Позволяет воспользоваться данными о часовых поясах с www.iana.org/time-zones
import time
d: date = date.today()
dt1: datetime = datetime.today()
dt2: datetime = datetime.now()
dt3: datetime = datetime.now(pytz.timezone('US/Pacific'))
t1 = time.time() # Эпоха Unix
t2 = time.ctime()
print (f"{d}\n {dt1}\n {dt2}\n {dt3}\n {t1}\n {t2}")
2025-05-02
2025-05-02 16:47:58.022559
2025-05-02 16:47:58.022588
2025-05-02 04:47:58.022633-07:00
1746186478.0226727
Fri May 2 16:47:58 2025
Timezone
Часовые пояса.
from datetime import datetime, tzinfo
from dateutil.tz import UTC, tzlocal, gettz
tz1: tzinfo = UTC # Часовой пояс UTC
tz2: tzinfo = tzlocal() # Местный часовой пояс
tz3: tzinfo = gettz() # Местный часовой пояс
tz4: tzinfo = gettz("America/Chicago") # Или, например, "Asia/Kolkata". Полный список: en.wikipedia.org/wiki/List_of_tz_database_time_zones
local_dt = datetime.today()
utc_dt = local_dt.astimezone(UTC) # Конвертация местного часового пояса в часовой пояс UTC
print (f"{tz1}\n {tz2}\n {tz3}\n {tz4}\n {local_dt}\n {utc_dt}")
tzutc()
tzlocal()
tzlocal()
tzfile('US/Central')
2024-03-06 15:30:35.706820
2024-03-06 10:30:35.706820+00:00
Монотонное время
Монотонное время (monotonic clock) - подход, гарантирующий отсутствие сдвигов назад по временной шкале.
В начале такой подход может показаться несколько странным — в самом деле, зачем нам "неубывающее" время, если это противоречит концепции постоянной корректировки времени для получения максимальной точности, например, при помощи NTP — но в дальнейшем, с погружением в тонкости синхронизации данных в распределённых системах, становится понятно, что в монотонном времени есть рациональное зерно.
Применение монотонного времени гарантирует, что при запросе, который был позже, время будет не меньше (то есть таким же или большим). Это позволяет понять, например, какой запрос на запись пришёл раньше, какой позже, и уже в соответствии с этим строить тактику обработки данных.
Точка отсчёта значения, возвращаемого монотонными часами, не определена, поэтому допустима только разница между результатами последовательных вызовов, а не абсолютное время.
Модуль time в Python имеет две функции работы с монотонными часами — monotonic (возвращает float) и monotonic_ns (возвращает int).
import time
t_start = time.monotonic()
time.sleep(1)
t_stop = time.monotonic()
t_run = t_stop - t_start
print(t_start, t_stop, t_run)
19160.5618931 19161.5629457 1.0010526000005484
import time
t_start = time.monotonic_ns()
time.sleep(1)
t_stop = time.monotonic_ns()
t_run = t_stop - t_start
print(t_start, t_stop, t_run)
19162858465500 19163858838000 1000372500
Необходимо понимать, что монотонное время — только один из подходов, применяемых при построении процедур обработки данных; он требует вдумчивого подхода и сам по себе не гарантирует корректной обработки запросов.
Функциональное программирование (map, filter, reduce, partial)
На случай, если начиная с этого момента и до конца текущего жизненного цикла вы собираетесь к месту и не месту использовать приёмы функционального программирования, чтобы сделать свой код «воистину крутым», просто процитирую вам Джоэля Граса, автора книги «Data Science: Наука о данных с нуля»: «В первом издании этой книги были представлены функции partial, map, reduce и filter языка Python. На своем пути к просветлению я понял, что этих функций лучше избегать, и их использование в книге было заменено включениями в список, циклами и другими, более Python'овскими конструкциями». Такие дела...
import functools
# Преобразует все входящие значения при помощи указанной функции
iter1 = map(lambda x: x + 1, range(10))
print(list(iter1))
# Передает в выходной итератор только значения, удовлетворяющие условию
iter2 = filter(lambda x: x > 5, range(10))
print(list(iter2))
# Применяет указанную функцию ко всей последовательности входных данных, сводя их к единственному значению
a = functools.reduce(lambda out, x: out + x, range(10))
print(a)
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
[6, 7, 8, 9]
45
import functools
def sum(a,b):
return a + b
add_const = functools.partial(sum, 10)
print(add_const(5))
15
Если вам не сразу станет понятно, как работает функция partial (и зачем она нужна), не расстраивайтесь, вы не одиноки :). Вот, пожалуйста, тема на Stackoverflow: «I am not able to get my head on how the partial works». Там, кстати, есть совет, как partial могут быть полезны при организации pipeline с включением функций, имеющих разное количество аргументов.
Any, all
any() вернет True, если хотя бы один элемент итерируемой коллекции истинен, all() вернет True только в случае истинности всех элементов коллекции.
animals = ["Squirrel", "Beaver", "Fox"]
sentence = "Bison likes squirrels and beavers"
any_animal: bool = any(animal.lower() in sentence.lower() for animal in animals)
print(any_animal)
all_animal: bool = all(animal.lower() in sentence.lower() for animal in animals)
print(all_animal)
True
False
Файлы
Файловые операции стоят немного особняком от остальных методов обработки данных, как подразумевающие не сиюминутную торопливую обработку информации, а взаимодействие с неким постоянным энергонезависимым хранилищем данных. Так что если вам нужно сохранить данные на завтра, или, наоборот, нужно прочитать данные, которые вам предоставили неделю назад, то вам, очевидно, нужно будет работать с файлами. В файлах же осядет информация, которую мы передаем базам данных, но эту тему мы рассмотрим ниже.
f = open("f.txt", mode='r', encoding="utf-8", newline=None)
print(f.read())
Hello from file!
На всякий случай, если вы испытываете программистский зуд даже небольшой степени выраженности, напоминаю — обязательно прогоняйте в IDE все непонятные куски кода, не надо на них смотреть, их надо видоизменять, корректировать, дорабатывать; только когда концы свяжутся, только когда вы поймете, как функционирует этот кусочек кода, только тогда промелькнёт маленькая искорка и ваша квалификация как программиста немного подрастёт.
Режимы (mode):
"r" — чтение (поведение по умолчанию)
"w" — запись (информация, ранее присутствующая в файле, будет стёрта)
"x" — эксклюзивное создание и запись; если файл уже существует, будет выброшено исключение FileExistsError
"a" — открытие с последующим добавлением в конец файла
"w+" — чтение и запись
"r+" — чтение и запись с начала файла
"a+" — чтение и запись с конца файла
"t" — текстовый режим ("rt", "wt" и т. д.; поведение по умолчанию)
"b" — двоичный режим ("rb", "wb", "xb" и т. д.)
encoding=None — будет использована кодировка по умолчанию (зависит от системы, см. getpreferredencoding()). Если нет специальных требований, просто используйте везде encoding="utf-8"; без этого, например, русский текст запишется в текстовый файл в виде человеконечитаемой последовательности.
newline=None — при чтении системные символы конца строки будут конвертированы в "\n"; при записи, наоборот, "\n" будут конвертированы в системные символы конца строки.
Возможные исключения при работе с файлами:
FileNotFoundError при чтении в режиме "r" или "r+".
FileExistsError при записи в режиме "x".
IsADirectoryError, PermissionError — в любом режиме.
Чтение из файла
Открывает файл и возвращает файловый объект.
Для работы с файлами лучше использовать менеджеры контекста (рассмотрены ниже), т. е. конструкции вида "with open...". Даже если что-то пойдет не так, как задумано (например, вы не обработаете исключение во время работы с файлом), менеджер контекста «зачистит хвосты», и ваша оплошность не отразится на файловой системе.
with open("f.txt", encoding="utf-8") as f:
chars = f.read(5) # Reads chars/bytes or until EOF
print(chars)
f.seek(0) # Moves to the start of the file. Also seek(offset) and seek(±offset, anchor), where anchor is 0 for start, 1 for current position and 2 for end
lines: list[str] = f.readlines() # Also readline()
print(lines)
Hello
['Hello from file!']
Запись в файл
with open("f.txt", "w", encoding="utf-8") as f:
f.write("Hello from file!") # Или f.writelines(<collection>)
JSON
Человекочитаемый формат для хранения и передачи данных. Раньше с JSON конкурировали XML и CSV, но сейчас JSON, можно сказать, победил.
import json
d: dict = {1: "Lemon", 2: "Apple", 3: "Banana!"}
object_as_string: str = json.dumps(d, indent=2)
print(object_as_string)
restored_object = json.loads(object_as_string)
# Write object to JSON file
with open("1.json", 'w', encoding='utf-8') as file:
json.dump(d, file, indent=2)
# Read object from JSON file
with open("1.json", encoding='utf-8') as file:
restored_from_file = json.load(file)
print(restored_from_file)
{
"1": "Lemon",
"2": "Apple",
"3": "Banana!"
}
{'1': 'Lemon', '2': 'Apple', '3': 'Banana!'}
Было предпринято немало попыток сжать JSON (MessagePack, BSON, BJSON, UBJSON, BISON, Smile), но широкого распространения ни один из этих форматов не получил.
Пути (paths)
При работе с файлами не обойтись без манипулирования файловыми путями.
from os import getcwd, path, listdir
from pathlib import Path
s1: str = getcwd() # Возвращает текущую рабочую директорию
print(s1)
s2: str = path.abspath("f.txt") # Возвращает полный путь
print(s2)
s3: str = path.basename(s2) # Возвращает имя файла
s4: str = path.dirname(s2) # Возвращает путь без файла
t1: tuple = path.splitext(s2) # Возвращает кортеж из пути и имени файла
print(s3, s4, t1)
p = Path(s2)
st = p.stat()
print(st)
b1: bool = p.exists()
b2: bool = p.is_file()
b3: bool = p.is_dir()
print(b1, b2, b3)
c: list = listdir(path=s1) # Возвращает список имен файлов, находящихся по указанному пути
print(c)
s5: str = p.stem # Возвращает имя файла без расширения
s6: str = p.suffix # Возвращает расширение файла
t2: tuple = p.parts # Возвращает все элементы пути как отдельные строки
print(s5, s6, t2)
c:\Works\amaargiru\pycore
c:\Works\amaargiru\pycore\f.txt
f.txt c:\Works\amaargiru\pycore ('c:\\Works\\amaargiru\\pycore\\f', '.txt')
os.stat_result(st_mode=33206, st_ino=2251799814917120, st_dev=3628794147, st_nlink=1, st_uid=0, st_gid=0, st_size=16, st_atime=1662468638, st_mtime=1662468638, st_ctime=1661089564)
True True False
['.git', '.gitignore', '.pytest_cache', '01_python.ipynb', '01_python.md', '02_postgre.md', '03_architecture.md', '04_algorithms.ipynb', '04_algorithms.md', '05_admin_devops.md', '06_pytest_mock.ipynb', '06_pytest_mock.md', '07_fastapi.md', '08_flask.md', '1.bin', '1.json', 'compose_readme.bat', 'coupling_vs_cohesion.svg', 'f.txt', 'gitflow.svg', 'graph_for_dfs.jpg', 'pycallgraph3.png', 'readme.md']
f .txt ('c:\\', 'Works', 'amaargiru', 'pycore', 'f.txt')
Pickle
Бинарный формат для хранения и передачи данных.
Pickle экономит место, но не сильно (uses a relatively compact binary representation), так что в случае необходимости сильного сжатия информации можно рассмотреть использование специализированных алгоритмов, например, lzma.
import pickle
d: dict = {1: "Lemon", 2: "Apple", 3: "Banana!"}
# Запись объекта в бинарный файл
with open("1.bin", "wb") as file:
pickle.dump(d, file)
# Чтение объекта из файла
with open("1.bin", "rb") as file:
restored_from_file = pickle.load(file)
print(restored_from_file)
{1: 'Lemon', 2: 'Apple', 3: 'Banana!'}
Protocol Buffers
Если вы хотите передавать и хранить данные, используя универсальную структуру, одинаково хорошо понимаемую всеми языками программирования (как JSON) и занимающую мало места (как Pickle), то можно посмотреть в сторону Protocol Buffers (Wikipedia, примеры для Python). Есть еще альтернативы, например, FlatBuffers, Apache Avro или Thrift.
Многие информационные системы также реализуют какое-либо проприетарное двоичное кодирование для своих данных. Например, у большинства реляционных БД есть сетевой протокол, по которому можно отправлять запросы к базе и получать на них ответы. Такой протокол обычно свой для каждой конкретной СУБД, и производители БД предоставляют драйверы (например, на основе API ODBC или JDBC), декодирующие получаемые по сетевому протоколу базы ответы в структуры, располагаемые в оперативной памяти.
Сейчас мы просто упомянем о Protocol Buffers в контексте перехода от текстовых форматов передачи данных к бинарным, подробности рассмотрим позже в главе "Сетевые возможности".
IOBase
IOBase — это базовый класс для всех потоков ввода-вывода, который нельзя использовать напрямую. Он определяет общие методы, но не реализует их.
from io import IOBase
file = open('f.txt', 'r')
print(isinstance(file, IOBase)) # Проверка, является ли объект потоком
file.close()
True
StringIO
Поток для работы с текстом. Имитирует файл в памяти для работы со строками.
from io import StringIO
stream = StringIO() # Создание "виртуального файла"
stream.write('First string.\n') # Запись данных
stream.write('Second string.')
stream.seek(0) # Перемещение курсора в начало
# Чтение данных
print(stream.read()) # Выведет все содержимое
stream.close()
First string.
Second string.
BytesIO
Работает с данными.
from io import BytesIO
stream = BytesIO()
# Запись
stream.write(b'\x48\x65\x6c\x6c\x6f') # 'Hello'
stream.seek(0)
# Чтение
print(stream.read())
stream.close()
b'Hello'
RawIOBase
Используется для небуферизированных низкоуровневых операций.
from io import RawIOBase
file = open('f.txt', 'rb', buffering=0) # Открытие файла в небуферизованном режиме
# Проверка типа
print(isinstance(file, RawIOBase))
# Чтение байтов
data = file.read(4)
print(data) # Первые 4 байта файла
file.close()
True
b'Hell'
BufferedIOBase
Класс BufferedIOBase предоставляет буферизованные байтовые потоки. Буферизация позволяет минимизировать количество операций ввода-вывода за счет накопления данных в памяти перед их записью или чтением. BufferedIOBase можно использовать для повышения производительности при частых мелких операциях чтения-записи.
from io import BufferedIOBase
import sys
# Открытие файла в буферизованном режиме (по умолчанию)
with open("data.bin", "wb") as file:
print(isinstance(file, BufferedIOBase)) # Проверка типа
file.write(b"Hello, BufferedIOBase!\n") # Запись в буфер
file.flush() # Принудительный сброс буфера на диск
True
stdin, stdout, stderr
stdin – стандартный поток ввода, stdout – стандартный вывод, stderr – стандартный вывод ошибок. Стандартные потоки доступны с помощью модуля sys.
import sys
# Чтение из stdin
print("Введите текст:")
user_input = sys.stdin.readline()
print(f"Вы ввели: {user_input}")
# Вывод в stdout и stderr
sys.stdout.write("Это обычный вывод\n") # Аналог print()
sys.stderr.write("Это ошибка!\n")
Это обычный вывод
Это ошибка!
12
Поток stdout буферизируется, поэтому вывод может «тормозить». Например, при выполнении print() в цикле данные могут накапливаться в буфере и выводиться пачкой (обходится при помощи sys.stdout.flush()). stderr не буферизируется, поэтому сообщения об ошибках выводятся сразу же.
3. Потоки данных
Itertools
Методы модуля itertools возвращают итераторы.
Итератор — механизм поэлементного обхода данных, который использует метод next() для получения следующего значения последовательности. Подробнее создание итераторов будет рассмотрено ниже, в разделе «ООП / Утиная типизация». В «нормальные» данные итераторы превращаются посредством for, next или list().
Itertools содержит множество готовых итераторов, которые могут быть бесконечными (порождаются при помощи count, cycle или repeat), конечными (accumulate, chain, takewhile и другие) и комбинаторными (product, combinations, combinations_with_replacement, permutations). Лучше изучить их все, хотя бы поверхностно, потому что даже относительно редко употребляемый метод, например, какой-нибудь zip_longest(), иногда весьма и весьма пригождается, идеально ложась на поставленную задачу.
Что такое итератор?
Итератор — класс, реализующий методы __next и __iter. Метод __next_ должен возвращать следующее значение итератора или выкидывать исключение StopIteration, сигнализируя, что итератор исчерпал доступные значения, метод __iter_\() должен возвращать "self".
Пример работы с бесконечными итераторами:
from itertools import count, repeat, cycle
# Итератор, возвращающий равномерно распределенные значения
i1 = count(start=0, step=.1)
print(next(i1))
print(next(i1))
print(next(i1))
# Итератор, циклично и бесконечно возвращающий элементы итерируемого объекта
i2 = cycle([1, 2])
print(next(i2))
print(next(i2))
print(next(i2))
# Итератор, возвращающий один и тот же объект бесконечно, если не указано значение аргумента times
i3 = repeat("Wow!", times=3)
print(list(i3))
0
0.1
0.2
1
2
1
['Wow!', 'Wow!', 'Wow!']
Применение некоторых конечных итераторов:
from itertools import accumulate, chain, compress, dropwhile, takewhile, pairwise
import operator
# Итератор, возвращающий накопленный результат выполнения указанной функции (по умолчанию — сложение)
i1 = accumulate([1, 2, 3, 4])
i2 = accumulate([1, 2, 3, 4], initial=10)
print(list(i1), list(i2))
i3 = accumulate([ -3, -2, -1, 1, 2, 3, 4], operator.mul)
print(list(i3))
# Можно использовать свою функцию
def myfunc(accumulated, current):
return accumulated + 2 * current
i4 = accumulate([1, 2, 3, 4], func=myfunc)
print(list(i4))
# Можно использовать лямбду (подробнее рассмотрены ниже)
i5 = accumulate([1, 2, 3, 4], lambda accumulated, current: accumulated + 2 * current)
print(list(i5))
# Итератор, возвращающий только те элементы входной последовательности,
# которые имеют соответствующий элемент, равный True или 1 в последовательности selectors
i6 = compress("ABCDEF", [1, 1, 1, 0, 0, 1])
print(list(i6))
# Итератор, отбрасывающий элементы входной последовательности, если результат выполнения функции равен True.
# Как только предикат становится False, то отбрасывание прекращается (предикат больше не применяется)
i7 = dropwhile(lambda x: x<5, [1, 4, 6, 4, 1, 1, 1, 0])
print(list(i7))
# takewhile, в отличие от dropwhile, наоборот, возвращает элементы входной последовательности,
# если результат выполнения функции равен True
i8 = takewhile(lambda x: x<5, [1, 4, 6, 0, 4, 1, 2, 1])
print(list(i8))
# Итератор, формирующий из нескольких входных последовательностей одну общую
i2 = chain(["A", "B", "C"],["D", "E", "F"],["G", "H", "I"])
print(list(i2))
# Кстати, такой же трюк можно провернуть при помощи обычной sum(), задав ей начальный параметр [] (т. е. пустой список)
a = sum([["A", "B", "C"],["D", "E", "F"],["G", "H", "I"]], [])
print(a)
# Возвращает элементы входной коллекции попарно
i6 = pairwise([1, 2, 3, 4, 5])
print(list(i6))
[1, 3, 6, 10] [10, 11, 13, 16, 20]
[-3, 6, -6, -6, -12, -36, -144]
[1, 5, 11, 19]
[1, 5, 11, 19]
['A', 'B', 'C', 'F']
[6, 4, 1, 1, 1, 0]
[1, 4]
['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I']
['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I']
[(1, 2), (2, 3), (3, 4), (4, 5)]
Комбинаторика
from itertools import product, combinations, combinations_with_replacement, permutations
# Создает множество, содержащее все упорядоченные пары элементов из входных множеств
a = product("abc", "xyz")
print(list(a))
b = product([0, 1], repeat=3)
print(list(b))
# Возвращает подпоследовательности длины r из элементов входного итерируемого объекта, повторяющиеся элементы не допускаются
c = combinations("abc", r=2)
print(list(c))
# Выдает перестановки элементов итерируемого объекта
d = permutations("abc", r=2)
print(list(d))
# Возвращает подпоследовательности длины r из элементов входного итерируемого объекта, повторяющиеся элементы допустимы
e = combinations_with_replacement("abc", r=2)
print(list(e))
[('a', 'x'), ('a', 'y'), ('a', 'z'), ('b', 'x'), ('b', 'y'), ('b', 'z'), ('c', 'x'), ('c', 'y'), ('c', 'z')]
[(0, 0, 0), (0, 0, 1), (0, 1, 0), (0, 1, 1), (1, 0, 0), (1, 0, 1), (1, 1, 0), (1, 1, 1)]
[('a', 'b'), ('a', 'c'), ('b', 'c')]
[('a', 'b'), ('a', 'c'), ('b', 'a'), ('b', 'c'), ('c', 'a'), ('c', 'b')]
[('a', 'a'), ('a', 'b'), ('a', 'c'), ('b', 'b'), ('b', 'c'), ('c', 'c')]
chunked
Разбивает итерируемый объект на списки заданного размера.
from more_itertools import chunked
data = [1, 2, 3, 4, 5, 6]
result = list(chunked(data, 2))
print(result)
[[1, 2], [3, 4], [5, 6]]
collapse
Преобразует вложенные итерируемые объекты в плоский список. Есть еще метод flatten, он преобразует только первый уровень вложенности.
from more_itertools import collapse
nested = [[1, 2], 3, [4, [5, 6]]]
result = list(collapse(nested))
print(result)
[1, 2, 3, 4, 5, 6]
sliding_window
Создает "скользящее окно" из нескольких последовательных элементов.
from more_itertools import sliding_window
data = [1, 2, "middle", 4, 5]
result = list(sliding_window(data, 3))
print(result)
[(1, 2, 'middle'), (2, 'middle', 4), ('middle', 4, 5)]
unique_everseen
Возвращает уникальные элементы, сохраняя порядок появления.
from more_itertools import unique_everseen
data = [1, 2, 2, 4, 3, 4]
result = list(unique_everseen(data))
print(result)
# Пример с лямбдой в качестве ключа (игнорирование регистра)
data = ["a", "A", "b", "B"]
result = list(unique_everseen(data, key=lambda x: x.lower()))
print(result)
[1, 2, 4, 3]
['a', 'b']
batched
Разбивает итерируемый объект на кортежи фиксированной длины. Если элементов недостаточно, последний кортеж может быть укорочен.
from more_itertools import batched
data = [1, 2, 3, 4, 5]
result = list(batched(data, 2))
print(result)
[(1, 2), (3, 4), (5,)]
take
Возвращает первые n элементов итерируемого объекта.
from more_itertools import take
data = [1, 2, 3, 4, 5]
result = list(take(3, data))
print(result)
# Если элементов меньше n
result = list(take(10, data))
print(result)
[1, 2, 3]
[1, 2, 3, 4, 5]
Enumerate
Иногда, при переборе объектов в цикле for, нужно получить не только сам объект, но и его порядковый номер. Разумеется, это можно сделать, создав дополнительную переменную, которая будет инкрементироваться на каждом шаге цикла. Однако, можно делать это удобнее, при помощи итератора enumerate, введенным в PEP-279. Enumerate — синтаксический сахар («introduces ... to simplify a commonly used looping idiom»), позволяющий проще и нагляднее работать с объектами, поддерживающими итерацию. Метод __next__() enumerate возвращает кортеж, содержащий значение индекса и соответствующее этому индексу значение.
В документации работа enumerate упрощенно объясняется через генератор:
def enumerate(sequence, start=0):
n = start
for elem in sequence:
yield n, elem
n += 1
На самом деле enumerate — не генератор, а итератор:
import collections
import types
e = enumerate("abcdef")
print(isinstance(e, enumerate))
print(isinstance(e, collections.Iterable))
print(isinstance(e, collections.Iterator))
print(isinstance(e, types.GeneratorType))
True
True
True
False
Enumerate реализован не на Python, а на C, и в его исходном коде, разумеется, нет ключевого слова yield.
Примеры использования enumerate:
values = ["a", "b", "c", "d"]
for count, value in enumerate(values):
print(count, value)
print("\n")
for count, value in enumerate(values, start=10 ):
print(count, value)
0 a
1 b
2 c
3 d
10 a
11 b
12 c
13 d
Генератор (generator)
Генератор — любая функция, содержащая ключевое слово yield и возвращающая итератор. Генератор не хранит в памяти все необходимые элементы, а просто содержит метод для вычисления очередного значения; результат может создаваться на основе математического алгоритма или брать элементы из другого источника данных (коллекция, файл, сетевое подключение и т. д.), при необходимости модифицируя их.
Пройти генератор в цикле можно только один раз, на каждом шаге возможно вычислить только следующий элемент, но не предыдущий. Элемент генератора нельзя извлечь по индексу, будет выброшена ошибка, т. к. генератор не поддерживает метод __getitem__.
Объекты-генераторы не дают выигрыша по времени и памяти в том случае, если вам нужно работать сразу со всеми элементами коллекции, а не только с каким-то одним. Объекты-генераторы не дают выигрыша по времени, если вы последовательно запрашиваете все возможные элементы. При этом выигрыш по памяти остаётся.
Что такое генератор?
Любая функция, содержащая ключевое слово yield и возвращающая итератор.
Бесконечный генератор:
def count(start, step):
current = start
while True:
yield current
current += step
c = count(100, 10)
print(next(c))
print(next(c))
print(next(c))
100
110
120
Конечный генератор. Также, как и конечный итератор, конечный генератор можно превратить в список при помощи list() (вы можете попробовать превратить в list и бесконечный генератор, но процесс рискует несколько затянуться :):
def count(start, stop, step):
current = start
while current <= stop:
yield current
current += step
c = count(100, 200, 10)
print(next(c))
print(next(c))
print(next(c))
print(list(c))
100
110
120
[130, 140, 150, 160, 170, 180, 190, 200]
Следует разделять итераторы и генераторы. Итератор — объект, который использует метод __next__() для получения следующего значения последовательности. Генератор — функция, которая позволяет отложено создавать результат при итерации.
В чем разница между итератором и генератором?
Итератор является более общей концепцией, чем генератор, и представляет собой любой объект, класс которого имеет методы __next и __iter. Генератор — это функция, содержащая хотя бы один метод yield, и возвращающая итератор.
Объявление генератора
Объявить генератор можно несколькими методами. Первый метод — объявить функцию с yield, как было показано выше.
Второй метод — использовать генераторное выражение (generator expression):
r = range(1, 11)
squares = (n**2 for n in r)
print(list(squares))
[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
Можно объединить генераторы или делегировать часть функционала генератора другому генератору при помощи конструкции yield from:
def subg():
yield 'World'
def generator():
yield 'Hello'
yield from subg()
yield '!'
for i in generator():
print(i, end = ' ')
Hello World !
До широкого распространения asyncio конструкция yield from использовалась для создания корутин на базе генераторов.
Замыкания (closures)
Формально, замыкание — это функция, которая сохраняет доступ к переменным из внешней области видимости, даже после того, как внешняя функция завершила работу. Важно подчеркнуть, что замыкания позволяют сохранять состояние между вызовами функции. На базе замыканий можно реализовать, например, функции, запоминающие своё состояние между вызовами (и тем самым избежать применения глобальных переменных) или декораторы, рассмотренные чуть ниже.
class Averager: # Пример функции, запоминающие своё состояние между вызовами, без использования замыкания
def __init__(self):
self.count = 0
self.total = 0
def add(self, n):
self.count += 1
self.total += n
return self.total / self.count
avg = Averager()
for n in [5, 10, 15, 15, 20, 4]:
print(avg.add(n))
5.0
7.5
10.0
11.25
13.0
11.5
def averager(): # А вот несколько более читаемый вариант с использованием замыкания
count = 0
total = 0
def add(n):
nonlocal count, total
count += 1
total += n
return total / count
return add
avg = averager()
for n in [5, 10, 15, 15, 20, 4]:
print(avg(n))
5.0
7.5
10.0
11.25
13.0
11.5
Если вы не видите прям уж разительных отличий между первым и вторым вариантами - ничего страшного. Есть языки программирования, в которых замыкания действительно крайне необходимы и позволяют обойти некоторые другие ограничения; в Python замыкания тоже полезны, но не до такой степени.
Вот вам еще пара классических примеров использования замыканий, счётчик вызовов и настраиваемый умножитель:
def create_counter():
count = 0 # Переменная внешней функции
def counter():
nonlocal count # Разрешаем изменение переменной
count += 1
return count
return counter # Возвращаем замыкание
# Создаем экземпляр счетчика
my_counter = create_counter()
print(my_counter())
print(my_counter())
print(my_counter())
1
2
3
def multiplier(n):
def multiply(x):
return x * n # Значение n сохранено из внешней области видимости
return multiply
# Создаем функции-умножители
double = multiplier(2)
triple = multiplier(3)
print(double(5))
print(triple(5))
10
15
Помните, что замыкания хранят ссылки на переменные, а не их значения; если переменная изменяется, это отразится в замыкании. Это важно учитывать, чтобы избежать неприятных сюрпризов, особенно внутри цикла или в асинхронном коде.
Декораторы (decorators)
Что такое декораторы?
Декоратор в широком смысле – паттерн проектирования, когда один объект изменяет поведение другого. Декораторы в Python — это, по сути, своеобразные «обёртки», которые дают нам возможность делать что-либо до или после того, что сделает декорируемая функция, не изменяя её. Можно сказать, что декоратор является просто синтаксическим сахаром для конструкции вида:
my_function = my_decorator(my_function)
def makebold(fn):
def wrapped():
return "<b>" + fn() + "</b>"
return wrapped
def makeitalic(fn):
def wrapped():
return "<i>" + fn() + "</i>"
return wrapped
# Разумеется, при последовательном применении нескольких декораторов играет роль порядок декорирования.
@makebold
@makeitalic
def hello():
return "Hello, world!"
print(hello())
<b><i>Hello, world!</i></b>
Декоратор, подсчитывающий время работы оборачиваемой функции:
import time
def perf_counter(function):
def counted(*args):
start_time = time.perf_counter_ns()
res = function(*args)
print(f"{time.perf_counter_ns() - start_time} ns")
return res
return counted
@perf_counter
def slow_sum(x, y):
time.sleep(1)
return x + y
print(slow_sum(1, 2))
1002478400 ns
3
Что такое декоратор?
Декоратор — «обёртка», паттерн проектирования, когда один объект изменяет поведение другого. Декоратор позволяет применять определенные действия до или после декорируемой функции и является синтаксическим сахаром для конструкции вида my_function = my_decorator(my_function).
Параметризованный декоратор
В декоратор можно передать и позиционные, и именованные аргументы — args и kwargs соответственно. Синтаксис декораторов с аргументами немного отличается — декоратор с аргументами должен возвращать функцию, которая принимает функцию и возвращает другую функцию. Так что в результате декоратор с аргументами должен возвращать обычный декоратор:
def text_wrapper(wrap_text):
def wrapped(function):
def wrapper(*args, **kwargs):
result = function(*args, **kwargs)
return f"{wrap_text}\n{result}\n{wrap_text}"
return wrapper
return wrapped
@text_wrapper('============')
def my_decorated_function(text):
return text
print(my_decorated_function('Hello, world!'))
============
Hello, world!
============
Еще один пример параметризированного декоратора:
def repeat(times):
def decorator(func):
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def say_hello(name):
"""Функция приветствия."""
print(f"Hello, {name}!")
say_hello("Bob")
print("\n")
print(say_hello.__name__)
print(say_hello.__doc__)
Hello, Bob!
Hello, Bob!
Hello, Bob!
wrapper
None
Обратите внимание на последние две строчки. Чтобы избежать потери информации об исходной функции, в декоратор можно добавить functools.wraps:
from functools import wraps
def repeat(times):
def decorator(func):
@wraps(func) # Сохраняем метаданные исходной функции
def wrapper(*args, **kwargs):
for _ in range(times):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(times=3)
def say_hello(name):
"""Функция приветствия."""
print(f"Hello, {name}!")
say_hello("Bob")
print("\n")
print(say_hello.__name__)
print(say_hello.__doc__)
Hello, Bob!
Hello, Bob!
Hello, Bob!
say_hello
Функция приветствия.
@lru_cache
Декоратор, кеширующий значения, возвращаемые функцией. Все аргументы функции должны быть хэшируемы.
import functools
def recursion_sum(n):
if n == 1:
return n
print(n, end=" ")
return n + recursion_sum(n - 1)
recursion_sum(5)
print("\n")
recursion_sum(9)
print("\n")
@functools.lru_cache
def recursion_sum2(n):
if n == 1:
return n
print(n, end=" ")
return n + recursion_sum2(n - 1)
recursion_sum2(5)
print("\n")
recursion_sum2(9)
5 4 3 2
9 8 7 6 5 4 3 2
5 4 3 2
9 8 7 6
45
Размер кеша по умолчанию 128 значений. Ограничение можно отменить при помощи 'maxsize=None'.
Небольшая справка: кроме вытеснения давно неиспользуемых данных (least-recently-used, LRU) есть еще вытеснение наименее часто используемых данных (least-frequently-used, LFU).
Пока мы не ушли далеко от темы кеша, погуглите заодно модуль weakref и WeakValueDictionary, позволяющие организовать более гибкую работу с кешем.
@cache
functools.cache был добавлен в версии 3.9. @cache - это просто обёртка над lru_cache(maxsize=None). Вот, собственно, полный исходный код (источник):
################################################################################
### cache -- simplified access to the infinity cache
################################################################################
def cache(user_function, /):
'Simple lightweight unbounded cache. Sometimes called "memoize".'
return lru_cache(maxsize=None)(user_function)
@cached_property
cached_property нужен для кэширования однократных тяжелых вычислений, где значение не меняется в течение всей жизни объекта. Работает как обычное свойство (property), не ломая инкапсуляцию.
Пример без cached_property (ручное кэширование):
class Circle:
def __init__(self, radius):
self.radius = radius
self._area = None # Ручное управление кэшем
@property
def area(self):
if self._area is None: # Проверка кэша
print("Вычисление площади...")
self._area = 3.14 * self.radius ** 2
return self._area
circle = Circle(5)
print(circle.area) # Площадь будет вычислена при первом вызове
print(circle.area) # Площадь берется из кэша
Вычисление площади...
78.5
78.5
Тот же пример с использованием cached_property:
from functools import cached_property
class Circle:
def __init__(self, radius):
self.radius = radius
@cached_property
def area(self):
print("Вычисление площади...")
return 3.14 * self.radius ** 2
circle = Circle(5)
print(circle.area) # Площадь будет вычислена при первом вызове
print(circle.area) # Площадь берется из кэша
Вычисление площади...
78.5
78.5
@total_ordering
@total_ordering из модуля functools используется для автоматического заполнения недостающих методов сравнения в классе. Если в классе определены eq и хотя бы один из методов сравнения (например, lt, le, gt, ge), то этот декоратор сгенерирует остальные методы автоматически.
Основное преимущество — сокращение кода, минимизация бойлерплейта. Вместо того чтобы писать все шесть методов сравнения, достаточно определить eq и, например, lt. Это делает код чище и проще в поддержке. Также снижается вероятность ошибок, так как не нужно вручную обеспечивать согласованность всех методов.
Имейте в виду, что методы генерируются динамически, и использование @total_ordering может привнести дополнительные задержки.
Вот пример класса без @total_ordering:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
return self.x == other.x and self.y == other.y
def __lt__(self, other):
return (self.x ** 2 + self.y ** 2) < (other.x ** 2 + other.y ** 2)
# Остальные методы тоже нужно прописать вручную:
def __le__(self, other):
return self < other or self == other
def __gt__(self, other):
return not (self <= other)
def __ge__(self, other):
return not (self < other)
def __ne__(self, other):
return not (self == other)
# Проверка
p1 = Point(1, 2)
p2 = Point(3, 4)
print(p1 < p2)
print(p1 >= p2)
True
False
А вот класс с использованием @total_ordering:
from functools import total_ordering
@total_ordering
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
return self.x == other.x and self.y == other.y
def __lt__(self, other):
return (self.x ** 2 + self.y ** 2) < (other.x ** 2 + other.y ** 2)
# Проверка
p1 = Point(1, 2)
p2 = Point(3, 4)
print(p1 < p2)
print(p1 >= p2) # Метод сравнения сгенерирован автоматически
print(p1 != p2) # Метод сравнения сгенерирован автоматически
True
False
True
Разумеется, если сравнения требуют сложной логики (например, сравнения по разным полям), необходима ручная реализация методов.
@singledispatch
@singledispatch — декоратор, позволяющий создавать перегруженные функции, то есть функции, которые ведут себя по-разному в зависимости от типа аргумента. Это полезно, когда нужно обрабатывать разные типы данных разными способами, сохраняя при этом чистоту кода и избегая множественных проверок isinstance.
Вариант без @singledispatch:
def process_data(data):
if isinstance(data, int):
return f"Целое число: {data}"
elif isinstance(data, list):
return f"Список длины {len(data)}"
elif isinstance(data, dict):
return f"Словарь с ключами: {', '.join(data.keys())}"
else:
raise TypeError("Неподдерживаемый тип")
print(process_data(10))
print(process_data([1, 2, 3]))
print(process_data({"a": 1}))
Целое число: 10
Список длины 3
Словарь с ключами: a
А вот вариант с использованием @singledispatch:
from functools import singledispatch
@singledispatch
def process_data(data):
raise TypeError("Неподдерживаемый тип")
@process_data.register
def _(data: int):
return f"Целое число: {data}"
@process_data.register
def _(data: list):
return f"Список длины {len(data)}"
@process_data.register
def _(data: dict):
return f"Словарь с ключами: {', '.join(data.keys())}"
# Проверка
print(process_data(10))
print(process_data([1, 2, 3]))
print(process_data({"a": 1}))
Целое число: 10
Список длины 3
Словарь с ключами: a
Как видите, код стал даже длиннее, но читается вроде бы слегка легче. К тому же, ваш линтер будет благодарен за снижение цикломатической сложности.
Таковы, в целом, базовые методы обработки данных в Python.
Далее мы углублённо обсудим более высокоуровневые принципы движения потоков данных, включая базы данных, REST API, RPC и асинхронную обработку данных при помощи брокеров сообщений. Но сначала давайте познакомимся с подходами объектно-ориентированного программирования.
4. Объектно-ориентированное программирование
Классы и объекты
Тут, конечно, было бы к месту кратенькое, минут на сорок, введеньице в тему классов и объектов, но в наш текущий формат такая мощная врезка не совсем укладывается. Попробую объяснить максимально просто, на доступных примерах из киновселенной «Чужих»:
объект — это один конкретный ксеноморф;
класс — это Королева ксеноморфов. Класс либо рожает ксеноморфа, либо может вступить в бой сам (@staticmethod);
метапрограммирование — это такая Супер-Королева, размером с «Сулако», которая рожает других Королев;
наследование — это ксеноморф из «Воскрешения», помните, миленький такой, взявший лучшее и от собственной генетической программы и от генов Рипли.
Мы попробуем вернуться к теме объектов с чуть более серьезным настроением позже, в главе «Архитектура», но, вообще, в объектно-ориентированном программировании нет ничего особо сложного; просто до него лучше дойти, предварительно немного погрязнув в поддержке обычного процедурного подхода, когда зачастую стоит выбор — всё-таки попробовать подлечить этот кусок кода или уже усыпить его и переписать всё по новой? Когда-то давно, когда я писал относительно несложные программы на ассемблере для микроконтроллеров, то читая Страуструпа, слегка недоумевал — зачем всё это? Чтобы осознать потребность в обуви, надо походить босиком.
Что такое класс?
Класс - модель для создания объектов, описывающая их внутреннюю структуру и поведение.
Магические методы
Специальные (называемые также magic или dunder) методы класса — перегрузка, позволяющая классам определять собственное поведение по отношению к операторам языка.
Магические они потому, что почти никогда не вызываются явно, их вызывают встроенные функции или синтаксические конструкции. Например, функция len() вызывает метод __len__() переданного объекта. Метод __add__(self, other) вызывается автоматически при сложении оператором +.
Примеры магических методов:
__init: конструктор класса
__add: сложение с другим объектом
__eq: проверка на равенство с другим объектом
__cmp: сравнение (больше, меньше, равно)
__iter: используется при подстановке объекта в цикл
__new: статический метод, вызываемый для создания экземпляра класса. Официальная документация разъясняет назначение этого метода достаточно чётко — метод предназначен в основном для того, чтобы позволить подклассам неизменяемых типов (таких, как int, str или tuple) настраивать создание экземпляров либо для переопределения в пользовательских метаклассах для настройки создания классов.
В чем разница сравнения через == и через is?
== использует __eq__ — магический метод проверки на равенство с другим объектом.
is проверяет, указывают ли две переменные на один объект в памяти, «вручную» такое сравнение можно произвести при помощи функции id().
print(dir(int), "\n")
class A: # An empty class
...
a = A()
print(dir(a), "\n")
print(repr(a), "\n")
print(str(a))
['__abs__', '__add__', '__and__', '__bool__', '__ceil__', '__class__', '__delattr__', '__dir__', '__divmod__', '__doc__', '__eq__', '__float__', '__floor__', '__floordiv__', '__format__', '__ge__', '__getattribute__', '__getnewargs__', '__gt__', '__hash__', '__index__', '__init__', '__init_subclass__', '__int__', '__invert__', '__le__', '__lshift__', '__lt__', '__mod__', '__mul__', '__ne__', '__neg__', '__new__', '__or__', '__pos__', '__pow__', '__radd__', '__rand__', '__rdivmod__', '__reduce__', '__reduce_ex__', '__repr__', '__rfloordiv__', '__rlshift__', '__rmod__', '__rmul__', '__ror__', '__round__', '__rpow__', '__rrshift__', '__rshift__', '__rsub__', '__rtruediv__', '__rxor__', '__setattr__', '__sizeof__', '__str__', '__sub__', '__subclasshook__', '__truediv__', '__trunc__', '__xor__', 'as_integer_ratio', 'bit_count', 'bit_length', 'conjugate', 'denominator', 'from_bytes', 'imag', 'numerator', 'real', 'to_bytes']
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__']
<__main__.A object at 0x000001A1FFF3AF20>
<__main__.A object at 0x000001A1FFF3AF20>
Что такое магические методы и для чего они нужны?
Специальные (называемые также magic или dunder) методы класса — перегрузка, позволяющая классам определять собственное поведение по отношению к операторам языка.
Магические они потому, что почти никогда не вызываются явно, их вызывают встроенные функции или синтаксические конструкции.Примеры магических методов:
__add: сложение с другим объектом
__cmp: сравнение (больше, меньше, равно)
__iter__: используется при подстановке объекта в цикл
Особенностью метода __init__ является то, что он не должен ничего возвращать. При попытке возврата данных будет сгенерировано исключение.
__repr_ (representation) возвращает более-менее машиночитаемое представление объекта, полезное для отладки.
Иногда __repr может содержать достаточно информации для восстановления объекта.
__str__ возвращает человеко-читаемое сообщение. Если __str_\ не определён, то str использует repr.
class Person: # A simple class with init, repr and str methods
def __init__(self, name: str):
self.name: str = name
def __repr__(self):
return f"Person '{self.name}'"
def __str__(self):
return f"{self.name}"
def say_hi(self):
print("Hi, my name is", self.name)
p = Person("Charlie")
p.say_hi()
print(repr(p))
print(str(p))
Hi, my name is Charlie
Person 'Charlie'
Charlie
Как создается объект в Python? Какая разница между new и init?
Для создания объекта применяется специальная функция — конструктор, которая называется по имени класса и возвращает объект класса.
Метод __new используется, когда нужно управлять процессом создания нового экземпляра, а __init — для контроля его инициализация. Поэтому __new возвращает новый экземпляр класса, а __init — ничего.
@property
Декоратор @property используется для определения методов, доступных как поля. Таким образом операции чтения/записи поля можно обрамить дополнительной логикой, например, проверкой допустимых значений входного аргумента или пересчетом внутренних переменных объекта.
import math
class Circle:
def __init__(self, radius, max_radius):
self._radius = radius
self.max_radius = max_radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value <= self.max_radius:
self._radius = value
else:
raise ValueError
@property
def area(self):
return 2 * self.radius * math.pi
circle = Circle(10, 100)
circle.radius = 20 # OK
# circle.radius = 101 # Raises ValueError
print(circle.area)
125.66370614359172
Что делает декоратор @property?
Декоратор @property используется для определения методов, доступных как поля. Таким образом операции чтения/записи поля можно обрамить дополнительной логикой, например, проверкой допустимых значений входного аргумента.
@staticmethod
Обычный метод (т. е. не помеченный декораторами @staticmethod или @classmethod) имеет доступ к свойствам конкретного экземпляра класса.
@staticmethod — метод, принадлежащий классу, а не экземпляру класса. Можно вызывать без создания экземпляра, т. к. метод не имеет доступа к свойствам экземпляра. При помощи @staticmethod помечают функционал, логически связанный с классом, но не требующий доступа к свойствам экземпляра.
@classmethod, cls, self
Если метод не должен иметь доступа к свойствам конкретного экземпляра класса (также, как @staticmethod), но должен иметь доступ к другим методам и переменным класса, то следует использовать @classmethod.
class B:
def foo(self, x):
print(f"Run foo({self}, {x})")
@classmethod
def class_foo(cls, x):
print(f"Run class_foo({cls}, {x})")
@staticmethod
def static_foo(x):
print(f"Run static_foo({x})")
b = B()
b.foo(1)
b.class_foo(1)
b.static_foo(1)
Run foo(<__main__.B object at 0x0000028571EA5850>, 1)
Run class_foo(<class '__main__.B'>, 1)
Run static_foo(1)
У @classmethod первым параметром должен быть cls (класс), а у обычного метода — self (экземпляр класса).
Для @staticmethod не требуется ни cls, ни self.
__dict__
Каждый класс и каждый объект имеет атрибут __dict. Это системный, определённый интерпретатором атрибут, его не нужно создавать вручную. __dict — словарь, который хранит пользовательские атрибуты; в нём ключом является имя атрибута, значением, соответственно, значение атрибута.
class Supercriminal:
publisher = 'DC Comics'
Riddler = Supercriminal()
print(Supercriminal.__dict__)
print(Riddler.__dict__)
Riddler.name = 'Edward Nygma'
print(Riddler.__dict__) # Values from object __dict__
print(Riddler.publisher) # Value from class __dict__
{'__module__': '__main__', 'publisher': 'DC Comics', '__dict__': <attribute '__dict__' of 'Supercriminal' objects>, '__weakref__': <attribute '__weakref__' of 'Supercriminal' objects>, '__doc__': None}
{}
{'name': 'Edward Nygma'}
DC Comics
Каждый раз при запросе пользовательского атрибута Python последовательно обыскивает сам объект, класс объекта и классы, от которых унаследован класс объекта.
Для чего нужен атрибут dict?
Все классы и объекты в Python имеют атрибут __dict. Это определённый интерпретатором атрибут, его не нужно создавать вручную. __dict — словарь, который хранит пользовательские атрибуты; в нём ключом является имя атрибута, значением, соответственно, значение атрибута.
__slots__
Если вы припомните разницу между списком и кортежем, а также между множеством и иммутабельным множеством, то заметите, что создатели Python пытаются по мере возможности предоставлять разработчикам выбор между удобство м и скоростью. К списку таких же особенностей языка, заточенных на увеличение производительности и уменьшение занимаемой памяти, относится и __slots__.
Вот официальная документация по __slots__, а вот дополнительные разъяснения от одного из разработчиков официальной документации. При выборе «slots или не slots» помните про существование PEP 412 – Key-Sharing Dictionary, который внёс некоторый раздрай в некогда однозначное отношение к __slots__.
__dict__, рассмотренный чуть выше — изменяемая структура, и вы можете на лету добавлять и удалять поля из класса, что удобно, но порой медленно. Вы можете разменять удобство на скорость и размер занимаемой памяти, создав __slots__ — жестко заданный список предопределенных атрибутов, резервирующий память, создание которого запрещает дальнейшее создание __dict__ и __weakref__. Слоты можно использовать, когда у класса может быть очень много полей, например, в ORM, либо когда критична производительность.
class Clan:
__slots__ = ["first", "second"]
clan = Clan()
clan.first = "Joker"
clan.second = "Lex Luthor"
# clan.third = "Green Goblin" # Raises AttributeError
# print(clan.__dict__) # Raises AttributeError
Слоты используются, скажем, в библиотеках requests (например, __slots__ = ["url", "netloc", "simple_url", "pypi_url", "file_storagedomain"]) или ORM peewee (__slots_\ = ('stack', '_sql', '_values', 'alias_manager', 'state')).
Наследование __slots__ имеет определенную специфику и будет рассмотрено ниже.
Чтобы было понятно, о каком приросте производительности и снижении потребления памяти идёт речь, сделаем простое сравнение:
import timeit
import pympler.asizeof # В нашем случае sys.getsizeof — не лучший вариант, берем стороннее решение
class NotSlotted:
pass
class Slotted:
__slots__ = 'foo'
not_slotted = NotSlotted()
slotted = Slotted()
def get_set_delete_fn(obj):
def get_set_delete():
obj.foo = "Never Ending Song of Love"
del obj.foo
return get_set_delete
ns = min(timeit.repeat(get_set_delete_fn(not_slotted)))
s = min((timeit.repeat(get_set_delete_fn(slotted))))
print(ns, s, f'{(ns - s) / s * 100} %')
print(pympler.asizeof.asizeof(not_slotted), 'bytes')
print(pympler.asizeof.asizeof(slotted), 'bytes')
0.10838449979200959 0.08712740009650588 24.39772066187959 %
280 bytes
40 bytes
Что такое slots?
Атрибут __slots__ позволяет ввести ограничение, задавая неизменяемый список атрибутов, которыми будет обладать экземпляр класса. За счет такого ограничения можно повысить скорость работы при доступе к атрибутам и сэкономить место в памяти.
На всякий случай напоминаю еще раз — прогоняйте все непонятные примеры кода в IDE, их можно и нужно анализировать, корректировать и видоизменять. Попробуйте, например, самостоятельно посмотреть потребление памяти объектов с __dict__ и __slots__. А заодно на практике испытайте давно напрашивающийся, и наконец появившийся в Python 3.10 симбиоз между __slots__ и dataclass.
Утиная типизация
Утиная типизация (duck types) — постулирование реализации интерфейса классом не через явное объявление, а через реализацию методов интерфейса. Так, каждый класс, реализующий методы __next__() и __iter__(), автоматически становится итератором, несмотря на отсутствие явного объявления (что-нибудь вроде @iterator) или, скажем, наследования от класса Iterator.
Iterator
Итератор — класс, реализующий методы __next__() и __iter__().
Метод __next__() должен возвращать следующее значение итератора или выкидывать исключение StopIteration, чтобы сигнализировать о том, что итератор исчерпал доступные значения.
Метод __iter__() должен возвращать "self".
class LimitCounter:
def __init__(self, max_value: int):
self.count = 0
self.max_value = max_value
def __next__(self):
self.count += 1
if self.count <= self.max_value:
return self.count
else:
raise StopIteration
def __iter__(self):
return self
limit_counter = LimitCounter(2)
print(next(limit_counter))
print(next(limit_counter))
# print(next(limit_counter)) # Raises StopIteration
1
2
Comparable
Начиная с Python 3.4, для того, чтобы экземпляры метода можно было сравнивать между собой, достаточно определить методы __lt__ (меньше) и __eq__ (равно), а также задействовать декоратор @functools.total_ordering.
from functools import total_ordering
@total_ordering
class Person:
def __init__(self, firstname: str, lastname: str):
self.firstname: str = firstname
self.lastname: str = lastname
def _is_valid_operand(self, other):
return hasattr(other, "lastname") and hasattr(other, "firstname")
def __eq__(self, other):
if not self._is_valid_operand(other):
return NotImplemented
return (self.lastname, self.firstname) == (other.lastname, other.firstname)
def __lt__(self, other):
if not self._is_valid_operand(other):
return NotImplemented
return (self.lastname, self.firstname) < (other.lastname, other.firstname)
Finn = Person("Finn", "the Human")
Jake = Person("Jake", "the Dog")
print(Finn != Jake)
True
Hashable
Хэшируемые объекты должны реализовывать методы __hash__() и __eq__(). Хеш объекта должен быть неизменен в течении всего жизненного цикла. Хешируемые объекты можно использовать как ключи в словарях и как элементы множеств, так как эти структуры используют хэш-таблицу для внутреннего представления данных.
Хэшируемые объекты, которые сравниваются между собой, должны иметь одинаковое хэш-значение, то есть стандартная hash(), возвращающая 'id(self)', не подойдет. Именно поэтому Python автоматически делает классы нехэшируемыми, если вы реализуете только eq().
class Hero:
def __init__(self, name: str, level: int):
self.name: str = name
self.level: int = level
def _is_valid_operand(self, other):
return hasattr(other, "name") and hasattr(other, "level")
def __eq__(self, other):
if not self._is_valid_operand(other):
return NotImplemented
return (self.name, self.level) == (other.name, other.level)
def __hash__(self):
return hash((self.name, self.level))
Finn = Hero("Finn the Human", 10_000)
Jake = Hero("Jake the Dog", 10_000)
print(hash(Finn))
print(hash(Jake))
-8707075988359731747
-2276052447712954388
Sortable
Для возможности применения к последовательностям объектов таких методов как sort() или max() необходимо, как и в случае Comparable, определить методы __lt (меньше) и __eq (равно), а также задействовать декоратор @functools.total_ordering.
Для более предсказуемого поведения объекта в условиях различного контекста (и, иногда, для оптимизации производительности) вы можете определить полное множество функций сравнения (__lt()__, __gt()__, __le__() и __ge__()).
Для примера создадим класс студентов, которых можно будет сортировать не по имени, а по среднему баллу:
from functools import total_ordering
from statistics import mean
@total_ordering
class Student:
def __init__(self, name: str, grades: list[int]):
self.name: str = name
self.grades: list[int] = grades
def _is_valid_operand(self, other):
return hasattr(other, "name") and hasattr(other, "grades")
def __eq__(self, other):
if not self._is_valid_operand(other):
return NotImplemented
return mean(self.grades) == mean(other.grades)
def __lt__(self, other):
if not self._is_valid_operand(other):
return NotImplemented
return mean(self.grades) < mean(other.grades)
# определим str для человеко-читаемой репрезентации объекта
def __str__(self):
return self.name + " " + str(mean(self.grades))
Melissa = Student("Melissa Andrew", [4, 3, 4, 5, 4])
Peter = Student("Peter Shining Jr.", [3, 3, 4, 5, 3])
Joe = Student("Just Joe", [5, 5, 4, 5, 5])
print([str(stud) for stud in sorted([Peter, Melissa, Joe], reverse=True)])
['Just Joe 4.8', 'Melissa Andrew 4', 'Peter Shining Jr. 3.6']
Callable
Для возможности вызова объекта в качестве функции необходимо реализовать метод __call__. Типы, поддерживающие возможность их вызова в качестве функции, могут принимать набор аргументов.
class Counter:
def __init__(self):
self.i = 0
def __call__(self):
self.i += 1
return self.i
counter = Counter()
print(counter())
print(counter())
print(counter())
1
2
3
@classmethod нельзя вызывать в качестве функции:
class Check():
@classmethod
def class_method(cls):
pass
@staticmethod
def static_method():
pass
def instance_method(self):
pass
for attr, val in vars(Check).items():
if not attr.startswith("__"):
print (attr, f"{'is' if callable(val) else 'is NOT'} callable")
class_method is NOT callable
static_method is callable
instance_method is callable
Контекстный менеджер
Код, размещенный внутри оператора with выполняется с особенностью: как до, так и после срабатывают события входа в блок with и выхода из него. Объект, который определяет логику событий, называется контекстным менеджером.
На уровне класса события определены методами __enter__ и __exit__:
__enter__ срабатывает в тот момент, когда ход исполнения программы переходит внутрь with. Метод может вернуть значение, оно будет доступно расположенному внутри блока with коду;
__exit__ срабатывает в момент выхода блока, в т.ч. и в случае исключения. В этом случае в метод будет передана тройка значений (exc_class, exc_instance, traceback).
Самый распространённый контекстный менеджер — класс, порожденный функцией open. Он гарантирует, что файл будет закрыт даже в том случае, если внутри блока возникнет ошибка.
Желательно побыстрее выходить из контекстного менеджера, освобождая контекст и ресурсы.
with open('file.txt') as f:
data = f.read()
process_data(data)
В примере выше мы вышли из блока with сразу же после прочтения файла. Обработка данных происходит в основном блоке программы.
Чем контекстный менеджер отличается от блока try-finally?
В целом, эти две конструкции весьма близки. Официальная документация даже рекомендует использовать with для удобного обёртывания try-finally. Есть мнение, что контекстный менеджер позволяет более гибко обрабатывать ошибки.
Контекстные менеджеры можно использовать для временной замены параметров, переменных окружения, транзакций БД.
Напишем свой контекстный менеджер для подключения к БД SQLite:
import sqlite3
class db_conn:
def __init__(self, db_name):
self.db_name = db_name
# Открываем подключение к БД
def __enter__(self):
self.conn = sqlite3.connect(self.db_name)
return self.conn
# Закрываем подключение к БД
def __exit__(self, exc_type, exc_value, exc_traceback):
self.conn.close()
if exc_value:
raise
if __name__ == "__main__":
db = "test_context_connect.db"
with db_conn(db) as conn:
cursor = conn.cursor()
Что такое контекстный менеджер?
Контекстный менеджер — механизм, обеспечивающий безопасное выполнение кода, связанного с управлением внешними ресурсами.
Контекстный менеджер определяется методами __enter__ и __exit__. __enter__ срабатывает в момент перехода программы внутрь with. Метод может вернуть значение, оно будет доступно расположенному внутри блока with коду. __exit__ срабатывает в момент выхода из блока, в т.ч. и в случае исключения. В этом случае в метод будет передана тройка значений (имена аргументов на усмотрение разработчика) — exception_type (тип исключения), exception_instance (объект исключения), traceback (объект, содержащий информацию о последовательности вызовов, которые предшествовали исключению).
Контекстный менеджер на базе contextlib
Перепишем наш контекстный менеджер для подключения к БД SQLite при помощи contextlib:
import sqlite3
from contextlib import contextmanager
# Схема конструирования следующая: всё, что написано до оператора yield — вызывается в рамках функции __enter__, всё что после – в рамках __exit__.
@contextmanager
def db_conn(db_name):
# Открываем подключение к БД
conn = sqlite3.connect(db_name)
yield conn
# Закрываем подключение к БД
conn.close()
if __name__ == "__main__":
db = "test_contextlib_connect.db"
with db_conn(db) as conn:
cursor = conn.cursor()
Утиная типизация итерируемых объектов
Iterable
Iterable — объект, который для предоставления возможности поочерёдного прохода по всем своим элементам должен реализовывать метод __iter__(), возвращающий итератор. У каждого объекта с методом __iter__() автоматически начинает работать метод __contains__().
class MyIterable:
def __init__(self, *args):
self.a = list(args)
def __iter__(self):
return iter(self.a)
mi = MyIterable(1, 2, 3, 4)
print([el for el in mi])
print(1 in mi) # __contains__()
[1, 2, 3, 4]
True
Collection
Collection — объект, предоставляющий возможность поочерёдного прохода по всем своим элементам и обладающий конечным размером.
В дополнение к iter() должен быть реализован метод len(), возвращающий размер коллекции.
class MyCollection:
def __init__(self, *args):
self.a = list(args)
def __iter__(self):
return iter(self.a)
def __len__(self):
return len(self.a)
mc = MyCollection(1, 2, 3, 4)
print([el for el in mc])
print(1 in mc)
print(len(mc))
[1, 2, 3, 4]
True
4
Sequence
Требует методы len() and getitem(). getitem() должен отдавать элемент с требуемым индексом или вызывать исключение IndexError.
Автоматически будут порождены методы iter(), reversed() и contains().
class MySequence:
def __init__(self, a):
self.a = a
def __len__(self):
return len(self.a)
def __getitem__(self, i):
return self.a[i]
ABC Sequence
Коллекция Sequence из Abstract Base Classes for Containers предоставляет расширенный интерфейс по сравнению с обычной Sequence.
Всё так же требуя __getitem__ и __len__, предоставляет __contains_, __iter__, __reversed_\, index и count.
from collections import abc
class MyAbcSequence(abc.Sequence):
def __init__(self, a):
self.a = a
def __len__(self):
return len(self.a)
def __getitem__(self, i):
return self.a[i]
Таблица требуемых и доступных методов:
+------------+------------+------------+------------+--------------+
| | Iterable | Collection | Sequence | ABC Sequence |
+------------+------------+------------+------------+--------------+
| iter() | нужен | нужен | + | + |
| contains() | + | + | + | + |
| len() | | нужен | нужен | нужен |
| getitem() | | | нужен | нужен |
| reversed() | | | + | + |
| index() | | | | + |
| count() | | | | + |
+------------+------------+------------+------------+--------------+
И вообще, потщательнее присмотритесь с collections.abc, там есть множество заготовок, которые помогут вам сэкономить немало времени. Например, если к упомянутым __getitem__ и __len__ добавить __setitem__, __delitem__ и insert, то в ответ вы получите коллекцию MutableSequence, которая, кроме возможностей Sequence, имеет еще методы append, reverse, extend, pop, remove и __iadd__.
Копирование объектов
В Python оператор присваивания (=) не копирует объекты. Вместо этого он создает связь между существующим объектом и именем целевой переменной. Вот тут есть хорошее объяснение происходящего.
nums = [1, 2, 3]
other = nums
nums.append(4) # Изменятся и nums, и other
print(other)
[1, 2, 3, 4]
Чтобы создать копии объекта в Python, необходимо использовать модуль copy. Существует два способа создания копий объекта.
Shallow Copy (поверхностная копия) – копирует сам объект, вложенные объекты не копируются, они доступны по тем же ссылкам.
Deep Copy – рекурсивно копирует все вложенные объекты.
from copy import copy, deepcopy
class A:
def __init__(self, val: list):
self.val = val
def change_val(self, val: list):
self.val = val
a = A(list("one"))
# Просто копирование ссылки на объект
b = a # Assignment
# Создание нового объекта и копирование ссылок на объекты, найденные в изначальном объекте
c = copy(a) # Shallow copy
# Создание нового объекта с последующим рекурсивным копированием содержащихся внутри объектов
d = deepcopy(a) # Deep Copy
b.change_val(list("two"))
c.change_val(list("three"))
d.change_val(list("four"))
print(a.val, b.val, c.val, d.val)
print(id(a), id(b), id(c), id(d))
print(id(a.val[1]), id(c.val[1]))
['t', 'w', 'o'] ['t', 'w', 'o'] ['t', 'h', 'r', 'e', 'e'] ['f', 'o', 'u', 'r']
1795295519472 1795295519472 1793149281968 1793149273808
1795217224688 1795217321264
Что такое поверхностная копия? Что такое глубокая копия?
При поверхностном копировании вложенные объекты не копируются, копируются только ссылки на них. При использовании глубокого копирования рекурсивно копируются все вложенные объекты.
В Python 3.13 появилась возможность копировать и одновременно изменять копируемый объект при помощи copy.replace:
from collections import namedtuple
Point = namedtuple('Point', ['x', 'y'])
p1 = Point(20, 30)
print(f"Original Point: {p1}")
p2 = copy.replace(p1, x=25)
print(f"Modified Point: {p2}")
Original Point: Point(x=20, y=30)
Modified Point: Point(x=25, y=30)
Такой же финт можно провернуть с dataclass:
from dataclasses import dataclass
@dataclass
class Employee:
name: str
deparment: str
user1 = Employee('Alice', 'HR')
user2 = copy.replace(user1, name='Bob')
print(user1)
print(user2)
Employee(name='Alice', deparment='HR')
Employee(name='Bob', deparment='HR')
Наследование
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
class Employee(Person):
def __init__(self, name, age, staff_num, email):
super().__init__(name, age)
self.staff_num = staff_num
self.email = email
Множественное наследование (MRO)
При множественном наследовании порядок разрешения методов (method resolution order, MRO) позволяет Питону выяснить, из какого родительского класса нужно вызывать метод, если он не обнаружен непосредственно в классе-потомке.
class PrivateStaffData:
def __init__(self, private_email):
self.private_email = private_email
class PublicStaffData:
def __init__(self, work_email):
self.work_email = work_email
class StaffData(PrivateStaffData, PublicStaffData):
def __init__(self, private_email, work_email):
super().__init__()
print(StaffData.mro())
[<class '__main__.StaffData'>, <class '__main__.PrivateStaffData'>, <class '__main__.PublicStaffData'>, <class 'object'>]
MRO строит иерархию наследования таким образом, чтобы более специфичные методы класса-потомка перекрывали менее специфичные методы класса-предка. MRO строит упорядоченный список классов, в которых будет производиться поиск метода слева направо (линеаризация класса).
Для решения проблемы ромбовидной структуры (которая неявно присутствует даже в простейшем случае, так как все классы наследуются от object) линеаризация должна быть монотонной. Монотонность — свойство, которое требует соблюдения в линеаризации класса-потомка того же порядка следования классов-прародителей, что и в линеаризации класса-родителя. Линеаризация по сути является топологической сортировкой. В ранних версиях Python использовался алгоритм DLR, сейчас в ходу C3-линеаризация.
Если после удовлетворения свойства монотонности остаётся больше одного варианта линеаризации, то применяется порядок локального старшинства (local precedence ordering), т. е. порядок соблюдения для классов-родителей в линеаризации класса-потомка того же порядка, что и при его объявлении. Например, если класс объявлен как D(A, B, C), то в линеаризации D класс A должен стоять раньше B, а класс B — раньше C.
Если разрешение всех конфликтов при линеаризации невозможно, то остается три пути:
1 — переменой мест классов-предков в объявлении класса-потомка (но это помогает далеко не всегда);
2 — пересмотр иерархии наследования;
3 — определение своей собственной линеаризации через метаклассы при помощи метода mro(cls). Но при данном подходе надо быть готовым к тому, что будет использован менее специфичный метод класса-родителя вместо более специфичного метода класса-потомка.
При задании своей собственной линеаризации Python отключает встроенные проверки.
Что такое MRO?
MRO (method resolution order, порядок разрешения методов) — механизм, позволяющий при множественном наследовании определить родительский класс. MRO строит упорядоченный список классов, в которых будет производиться поиск метода слева направо (производит линеаризацию класса).
Mixin
Миксин — класс, предназначенный для добавления определенной функциональности к другим классам через наследование. Он не является самостоятельным классом, а служит скорее для сведения однотипного кода в одно место, позволяя избежать дублирования.
import json
# Миксин для преобразования объекта в JSON
class JsonMixin:
def to_json(self):
return json.dumps(self.__dict__)
# Миксин для преобразования объекта в текст
class TextMixin:
def to_text(self):
return f"{self.__dict__}"
# Класс Animal использует оба миксина
class Animal(JsonMixin, TextMixin):
def __init__(self, name, species):
self.name = name
self.species = species
# Создаем объект и используем методы миксинов
dog = Animal("Buddy", "Dog")
print(dog.to_json())
print(dog.to_text())
{"name": "Buddy", "species": "Dog"}
{'name': 'Buddy', 'species': 'Dog'}
@abstractmethod
Абстрактный класс в Python — аналог интерфейса в других языках (например, в C#) — класс, содержащий только сигнатуры методов, без реализации. Реализация методов переложена на классы-потомки. Задача абстрактного класса соответствует задаче интерфейса — обязать классы-потомки реализовывать все методы, заложенные в классе-родителе.
import abc
class AbstractClass(metaclass=abc.ABCMeta):
@abc.abstractmethod
def return_anything(self):
return
class ConcreteClass(AbstractClass):
def return_anything(self):
return 42
c = ConcreteClass()
print(c.return_anything())
42
Если не специфицировать return_anything() в ConcreteClass, при попытке вызвать c.return_anything() будет выброшено исключение _TypeError: Can't instantiate abstract class ConcreteClass with abstract method returnanything.
Наследование классов со __slots__
При одиночном наследовании __slots__ нормально наследуется, но это не предотвращает создание __dict__:
class SlotsClass:
__slots__ = 'foo', 'bar'
class ChildSlotsClass(SlotsClass):
...
obj = ChildSlotsClass()
print(obj.__slots__)
obj.something_new = "underwater stones"
print(obj.__dict__)
('foo', 'bar')
{'something_new': 'underwater stones'}
Для ограничения дочернего класса слотами нужно в нём снова присвоить значение атрибуту __slots__, родительские поля дублировать не нужно.
class SlotsClass:
__slots__ = 'foo', 'bar'
class ChildSlotsClass(SlotsClass):
__slots__ = 'baz'
obj = ChildSlotsClass()
# obj.something_new = "underwater stones" # Raises AttributeError: 'ChildSlotsClass' object has no attribute 'something_new'
Множественное же наследование классов с непустыми __slots__ невозможно.
Abstract Base Classes
Перед тем, как перейти к собственно метапрограммированию, давайте напишем вот простенькую программу с использованием ABC (Abstract Base Classes). Мы это уже сделали чуть раньше, с @abstractmethod, просто повторим этот подход, чтобы взглянуть на этот код немного под другим углом. Что такое абстрактный класс, мы уже знаем, так что подняться еще на ступеньку выше будет нетрудно.
from abc import ABC, abstractmethod
# Определяем абстрактный класс
class Animal(ABC):
@abstractmethod
def sound(self):
pass # Абстрактный метод без конкретного наполнения
# Класс на базе абстрактного класса
class Cat(Animal):
def sound(self):
return "Meow!" # Конкретное действие
cat = Cat()
print(cat.sound())
Meow!
Поздравляю! Теперь вы владеете метапрограммированием! Ведь на самом деле ABC — это просто удобный класс, помогающий сделать код менее запутанным для тех, кто не очень хорошо знаком с идеей метапрограммирования.
Как указано в документации: "abc.ABC - a helper class that has ABCMeta as its metaclass. With this class, an abstract base class can be created by simply deriving from ABC avoiding sometimes confusing metaclass usage".
И даже исходный код этого класса выглядит вот так:
class ABC(metaclass=ABCMeta):
"""Helper class that provides a standard way to create an ABC using inheritance."""
__slots__ = ()
Метаклассы
Что такое класс? Это, в принципе, просто кусок кода, описывающий, как создать объект. Но в Python класс — это нечто большее, классы также являются объектами; как только используется ключевое слово class, Python исполняет команду и создаёт объект:
class A:
...
В памяти будет создан объект с именем A.
Классы, как и другие объекты, можно создавать на ходу:
def custom_class(name):
if name == "foo":
class Foo:
...
return Foo # Возвращает именно класс, а не экземпляр
else:
class Bar:
...
return Bar
MyClass = custom_class("foo")
print(MyClass) # Функция возвращает класс, а не экземпляр
print(my_class := MyClass()) # Можно создать экземпляр класса
<class '__main__.custom_class.<locals>.Foo'>
<__main__.custom_class.<locals>.Foo object at 0x000001F0ECF97610>
Но это не очень удобно, так как нам до сих пор приходится писать весь код класса.
Основная цель метаклассов — автоматически изменять класс в момент создания, генерируя классы в соответствии с текущим контекстом.
Сами по себе метаклассы достаточно просты и работают примерно следующим образом:
перехватывают создание класса,
изменяют класс,
возвращают модифицированный класс.
Но обычно логику работы метаклассов насыщают вещами вроде интроспекции или манипуляцией наследованием, поэтому конечный код выглядит достаточно громоздко.
Здесь неплохо было бы добавить еще пару страниц про ньюансы создания и работы метаклассов, но позвольте переадресовать вас на вот эту прекрасную статью.
При помощи метаклассов хорошо решаются задачи, например, генерации классов для ORM. Скажем, для
class Person(models.Model):
name = models.CharField(max_length=30)
age = models.IntegerField()
код
keanu = Person(name="Keanu Reeves", age=60)
print(keanu.age)
распечатает число, взятое из БД, потому что models.Model определяет __metaclass__, который сотворит некоторую магию и превратит класс Person, определённый достаточно простым выражением, в сложную привязку к базе данных.
Что такое метаклассы?
Классы, как и прочие объекты, можно создавать во время исполнения программы. Основная цель метаклассов — автоматически изменять класс в момент создания, генерируя классы в соответствии с текущим контекстом. Сами по себе метаклассы достаточно просты и работают примерно следующим образом:
перехватывают создание класса,
изменяют класс,
возвращают модифицированный класс.
Если вы всё еще ломаете голову над местом метапрограммирования в своём текущем проекте, чтобы потом можно было упомянуть об этом в резюме, то вот вам на всякий случай цитата из Тима Питерса: «[Metaclasses] are deeper magic than 99% of users should ever worry about. If you wonder whether you need them, you don’t (the people who actually need them know with certainty that they need them, and don’t need an explanation about why)».
5. Внутренности языка
«Python — это эксперимент по определению степени свободы программистов. Слишком много свободы, и никто не может читать чужой код; слишком мало — и выразительность находится под угрозой.»
Гвидо Ван Россум.
Области видимости (scopes)
В Python видимость переменных определяется правилом LEGB, которое описывает порядок поиска имен (переменных, функций и классов) в различных областях видимости (scopes). Это ключевой механизм для понимания работы с переменными.
Правило LEGB
Интерпретатор Python использует следующий порядок поиска:
(L)ocal: локальная область видимости внутри текущей функции.
(E)nclosing: область видимости во внешних (охватывающих) функциях (если есть вложенность). Это нелокальная (nonlocal) область для текущей функции.
(G)lobal: глобальная область видимости модуля (файла .py).
(B)uilt-in: встроенная область видимости (встроенные функции и типы, такие как print, len, int, list).
Поиск останавливается, как только имя найдено в одной из областей. Если имя не найдено ни в одной из областей, генерируется исключение NameError.
local
Локальная область видимости (local scope) — это изолированное пространство внутри функции, где создаются и существуют переменные, объявленные непосредственно в этой функции (включая её параметры).
Эти переменные доступны только в рамках тела данной функции и не видны за её пределами. Они автоматически создаются при вызове функции и уничтожаются после её завершения, что обеспечивает инкапсуляцию и предотвращает непреднамеренные побочные эффекты. При поиске имени интерпретатор сначала проверяет именно локальную область, следуя правилу LEGB. Любое присваивание значения переменной внутри функции по умолчанию создаёт новую локальную переменную, если только явно не указано иное с помощью ключевых слов nonlocal или global.
def my_func():
local_var = 10 # Локальная переменная
print(local_var) # Здесь переменная видна
my_func()
print(local_var) # Здесь переменная не видна, будет выброшено NameError
10
---------------------------------------------------------------------------
NameError Traceback (most recent call last)
Cell In[7], line 6
3 print(local_var) # Здесь переменная видна
5 my_func()
----> 6 print(local_var) # Здесь переменная не видна, будет выброшено NameError
NameError: name 'local_var' is not defined
nonlocal
Ключевое слово nonlocal указывает интерпретатору, что данное имя должно искаться в области видимости внешних функций (начиная с ближайшей), а не создавать новую локальную переменную.
nonlocal позволяет изменять значение этой переменной во внутренней функции, предотвращая создание новой локальной копии. Без nonlocal попытка присвоения значения переменной из внешней функции приведёт к созданию отдельной локальной переменной во внутренней функции, оставляя исходную переменную неизменной. Таким образом, nonlocal обеспечивает доступ к переменным промежуточных областей видимости между локальной и глобальной, сохраняя их состояние между вызовами внутренней функции. Это особенно важно для работы с замыканиями и для сохранения контекста.
def outer():
outer_var = "Я в outer!" # Нелокальная (enclosing) для inner()
def inner():
nonlocal outer_var # Говорим Python, что мы хотим ИЗМЕНЯТЬ (а не просто читать) outer_var из области outer
outer_var = "Изменено в inner!" # Изменяем переменную из outer
print("inner:", outer_var)
inner()
print("outer:", outer_var) # Изменённая в outer переменная
outer()
inner: Изменено в inner!
outer: Изменено в inner!
global
Ключевое слово global позволяет функции модифицировать переменные, объявленные на глобальном уровне модуля (вне всех функций и классов). Без его использования присваивание значения переменной внутри функции по умолчанию создаёт новую локальную переменную, оставляя глобальную неизменной.
Объявление global явно связывает имя внутри функции с существующей глобальной переменной, разрешая её чтение и изменение в локальном контексте. Это необходимо только для операций записи (присваивания), так как чтение глобальных переменных работает неявно. Однако злоупотребление global нарушает инкапсуляцию и усложняет отладку, поэтому считается антипаттерном в чистом коде.
global_var = "Я глобальная!" # Глобальная переменная
def func1():
print("func1:", global_var) # Чтение глобальной переменной - работает и без global
def func2():
global global_var # Объявляем, что будем ИЗМЕНЯТЬ глобальную переменную
global_var = "Изменено func2!" # Изменяем глобальную переменную
def func3():
global_var = "Локальная в func3!" # Создает НОВУЮ локальную переменную, не трогает глобальную
print("До:", global_var) # До: Я глобальная!
func1()
func2()
print("После func2:", global_var)
func3()
print("После func3:", global_var) # После func3: Изменено func2! (глобальная переменная не изменилась)
До: Я глобальная!
func1: Я глобальная!
После func2: Изменено func2!
После func3: Изменено func2!
Built-in
Встроенная область видимости (built-in scope) — это фундаментальный уровень Python, содержащий предопределённые имена всех встроенных функций (например, print(), len(), open()), типов данных (str, int, list), констант (True, None) и исключений (ValueError). Эти имена доступны автоматически в любом месте программы без необходимости импорта или объявления, образуя "последний рубеж" поиска по правилу LEGB. Интерпретатор обращается к ним, только если имя не найдено в локальной, нелокальной или глобальной областях.
def my_func():
# Python ищет 'len' в Local -> Enclosing (нет) -> Global (нет) -> Built-in -> Находит встроенную len
print(len([1, 2, 3])) # 3
my_func()
3
Встроенные имена можно случайно переопределить (например, присвоив list = 10 в глобальной области), что перекроет изначальный функционал до конца работы модуля; к счастью современные IDE, вроде PyCharm или VS Code, очень постараются не дать вам это сделать.
# Переопределение в глобальной области (не делайте так!)
len = 10 # Теперь имя 'len' в глобальной области ссылается на число
def another_func():
print(len) # Ищет: Local (нет) -> Enclosing (нет) -> Global -> Находит 10
another_func() # Выведет 10
# Встроенная len теперь недоступна по этому имени в этом модуле!
10
Сборщик мусора
Стандартный интерпретатор Python (CPython) использует для сборки мусора два алгоритма: подсчет ссылок (reference counting, неотключаемый механизм) и garbage collector (стандартный модуль gc из Python, отключаемый). Алгоритм подсчета ссылок не умеет определять циклические ссылки.
Циклические ссылки могут находиться только в “контейнерных” объектах, т.е. в объектах, которые могут хранить другие объекты, например в списках, словарях, классах и кортежах. GC не следит за простыми и неизменяемыми типами, за исключением кортежей. Некоторые кортежи и словари также исключаются из списка слежки при выполнении определенных условий. Со всеми остальными объектами гарантированно справляется алгоритм подсчета ссылок.
В отличие от алгоритма подсчета ссылок, циклический GC не работает постоянно, а запускается периодически. GC разделяет все объекты на 3 поколения. Новые объекты попадают в первое поколение. Если новый объект выживает процесс сборки мусора, то он перемещается в следующее поколение. Чем выше поколение, тем реже оно сканируется. Так как новые объекты зачастую имеют очень маленький срок жизни (являются временными), то имеет смысл опрашивать их чаще, чем те, которые уже прошли через несколько этапов сборки мусора.
В каждом поколении есть специальный счетчик и порог срабатывания, при достижении которого начинается процесс сборки мусора. Как только в Python создается какой-либо контейнерный объект, он проверяет эти пороги. Если условия срабатывают, то начинается процесс сборки мусора.
Стандартные пороги срабатывания для поколений установлены на 700, 10 и 10 соответственно, но всегда можно изменить их с помощью функций gc.get_threshold и gc.set_threshold.
Алгоритм поиска циклических ссылок: говоря кратко, GC проходит по всем объектам из выбранного поколения и временно удаляет все ссылки от каждого объекта. Все объекты, у которых после этого счетчик ссылок меньше двух, считаются недоступными и могут быть удалены.
Ручной отлов циклических ссылок возможен благодаря наличию у GC отладочному флагу DEBUG_SAVEALL, с которым все недоступные объекты будут добавлены в список gc.garbage:
gc.set_debug(gc.DEBUG_SAVEALL)
Список gc.garbage, в свою очередь, можно визуализировать с помощью objgraph (для этого нужно установить Graphviz):
import objgraph
x = []
y = [x, [x], dict(x=x), set({'a', 'b', 'c'})]
objgraph.show_refs([y], filename='garbage-graph.png')
Graph written to C:\Users\HOMEOW~1\AppData\Local\Temp\objgraph-zmelodkb.dot (8 nodes)
Image generated as garbage-graph.png
В других интерпретаторах Python имеются другие механизмы сборки мусора. Например, в сборщике мусора интерпретатора PyPy (который называется Incminimark), можно полностью отключить GC при помощи команды gc.disable(), и использование памяти приложением будет расти бесконечно, пока вы не скомандуете gc.enable() или gc.collect().
В PyPy также есть также команда "собрать чуть-чуть мусора, постаравшись уложиться в одну миллисекунду" - gc.collect_step(). Сборка мусора выполняется небольшими порциями, что снижает задержки в работе программы. Это особенно важно для приложений, работающих в реальном времени.
Если в процессе работы вы строите большую структуру данных, которая вам точно не нужна сразу после использования, то имеет смысл вызвать сборщик мусора в ручном режиме для уменьшения фрагментации памяти:
import gc
del my_big_object
gc.collect()
На необходимость ручного вызова сборщика мусора есть разные точки зрения, но в целом такая процедура признаётся полезной (обсуждение, смотрите оживлённые комментарии к первому ответу).
Перехват исключений
Простой пример:
a: float = 0
b: float = 0
try:
b: float = 1/a
except ZeroDivisionError as e:
print(f"Error: {e}")
Error: division by zero
Более сложный пример.
Код в блоке else исполняется только в случае отсутствия исключения.
Код в блоке finally исполнится в любом случае, было ли вызвано исключение или нет.
import traceback
a: float = 0
b: float = 0
try:
b: float = 1/a
except ZeroDivisionError as e:
print(f"Error: {e}")
except ArithmeticError as e:
print(f"We have a bit more complicated problem: {e}")
except Exception as serious_problem: # Catch all exceptions
print(f"I don't really know what is going on: {traceback.print_exception(serious_problem)}")
else:
print("No errors!")
finally:
print("This part is always called")
Error: division by zero
This part is always called
Встроенные исключения
Сокращенное иерархическое дерево встроенных исключений показано ниже:
BaseException
+-- SystemExit # Raised by the sys.exit() function
+-- KeyboardInterrupt # Raised when the user press the interrupt key (ctrl-c)
+-- Exception # User-defined exceptions should be derived from this class
+-- ArithmeticError # Base class for arithmetic errors
| +-- ZeroDivisionError # Dividing by zero
+-- AttributeError # Attribute is missing
+-- EOFError # Raised by input() when it hits end-of-file condition
+-- LookupError # Raised when a look-up on a collection fails
| +-- IndexError # A sequence index is out of range
| +-- KeyError # A dictionary key or set element is missing
+-- NameError # An object is missing
+-- OSError # Errors such as “file not found”
| +-- FileNotFoundError # File or directory is requested but doesn't exist
+-- RuntimeError # Error that don't fall into other categories
| +-- RecursionError # Maximum recursion depth is exceeded
+-- StopIteration # Raised by next() when run on an empty iterator
+-- TypeError # An argument is of wrong type
+-- ValueError # When an argument is of right type but inappropriate value
+-- UnicodeError # Encoding/decoding strings to/from bytes fails
Полное дерево доступно здесь. Проблемы с тем или иным участком кода могу вызывать исключения разных типов, поэтому надо уметь ориентироваться в этом дереве.
Вызов исключений
from decimal import *
def div(a: Decimal, b: Decimal) -> Decimal:
if b == 0:
raise ValueError("Second argument must be non-zero")
return a/b
try:
c: Decimal = div(1, 0)
except ValueError as ve:
print(f"{ve}. We have ValueError, as a planned!")
# raise # We can re-raise exception
Second argument must be non-zero. We have ValueError, as a planned!
Выход из программы при помощи вызова исключения SystemExit
import sys
# sys.exit() # Exits with exit code 0 (success)
# sys.exit(8) # Exits with passed exit code
Исключения, определяемые пользователем
class MyException(Exception):
pass
raise MyException("My car is broken")
---------------------------------------------------------------------------
MyException Traceback (most recent call last)
c:\Works\amaargiru\pycore\05_language_skeleton.ipynb Cell 18 in <cell line: 4>()
<a href='vscode-notebook-cell:/c%3A/Works/amaargiru/pycore/05_language_skeleton.ipynb#X23sZmlsZQ%3D%3D?line=0'>1</a> class MyException(Exception):
<a href='vscode-notebook-cell:/c%3A/Works/amaargiru/pycore/05_language_skeleton.ipynb#X23sZmlsZQ%3D%3D?line=1'>2</a> pass
----> <a href='vscode-notebook-cell:/c%3A/Works/amaargiru/pycore/05_language_skeleton.ipynb#X23sZmlsZQ%3D%3D?line=3'>4</a> raise MyException("My car is broken")
MyException: My car is broken
Дополнение исключений
Начиная с Python 3.11 отлавливаемые исключения можно обогащать дополнительной информацией (PEP 678):
try:
raise TypeError('Bad type')
except Exception as e:
e.add_note('We are powerless, we rely on a higher authority')
raise
Группы исключений, except*
Начиная с Python 3.11, у разработчика появилась возможность использования так называемых групп исключений. Группы нужны для одновременной передачи и обработки более чем одного исключения. Такая ситуация возможна, например, когда вы хотите передать из параллельного потока в основной поток общий список ошибок, накопившихся при выполнении функции.
Структурированная обработка исключений при помощи @singledispatch
functools.singledispatch может помочь нам и на этот раз, структурировав обработку исключений разных типов. Имейти в виду, что singledispatch выбирает первый подходящий тип в иерархии MRO; для сложных иерархий используйте functools.singledispatchmethod или проверяйте типы вручную.
from functools import singledispatch
# Базовый обработчик
@singledispatch
def handle_exception(e):
"""Обработчик по умолчанию."""
print(f"Unhandled exception: {e!r}")
# Можно повторно вызвать исключение, если нужно:
# raise
# Регистрация обработчиков для конкретных типов исключений
@handle_exception.register(ValueError)
def _(e):
print(f"ValueError handled: {e}")
# Логика для ValueError, например, возврат default значения
return 0
@handle_exception.register(TypeError)
def _(e):
print(f"TypeError handled: {e}")
# Логика для TypeError
return None
@handle_exception.register(ZeroDivisionError)
def _(e):
print(f"ZeroDivisionError handled: {e}")
# Возвращаем fallback-значение
return float('inf')
# Пример использования
def risky_operation(x, y):
try:
return x / y
except Exception as e:
return handle_exception(e)
# Тестирование
print(risky_operation(10, 2))
print(risky_operation(10, 0))
print(risky_operation("10", 2))
5.0
ZeroDivisionError handled: division by zero
inf
TypeError handled: unsupported operand type(s) for /: 'str' and 'int'
None
Та или иная тактика использования исключений — довольно спорная тема, так как систематизация обработки ошибок сильно пересекается с темой общей архитектуры приложения. Поэтому кто-то предлагает использовать обёртки Success/Failure, кто-то создаёт свои классы исключений, которые имеют расширенные функции логгирования и призваны облегчить отладку.
Интересно, что создатели относительно нового языка программирования Go, имея перед глазами самые свежие спецификации языков, в том числе и C#, и Python, имеющих развитые методы работы с исключениями, сознательно отказались от структурной обработки исключений, возвращая код ошибки как один из результатов функции, так что породило многословность обработки обработки ошибок в Go и даже карикатуры, вроде этой:
Одним словом, если философия обработки ошибок в Python кажется вам не совсем "гладкой", не переживайте — вы не одиноки.
Лично я предпочитаю путь, который, можно назвать «классическим»:
много исключений на этапе отладки, которые помогают сделать отдельные функции более стабильными;
каждое ожидаемое исключение должно быть обработано как можно раньше;
на самый верх должны проникнуть только неожиданные исключения (которые, в результате, попадут или в отчет тестировщика или в баг-репорт пользователя и тоже будут купированы).
Одинарное (_) и двойное (__) подчеркивания. Name mangling.
Python не использует спецификаторы доступа, такие как private, public, protected и т. д. Однако, в нем есть имитации поведения переменных путем использования одинарного или двойного подчеркивания в качестве префикса к именам переменных. По умолчанию переменные без подчеркивания являются общедоступными.
Поле класса с одним лидирующим подчеркиванием говорит о том, что параметр используется только внутри класса. При этом он доступен для обращения извне.
class Foo(object):
def __init__(self):
self._bar = 42
Foo()._bar
42
Современные IDE вроде PyCharm подсвечивают обращение к полю с подчеркиванием, но ошибки в процессе исполнения не будет.
Поля с двойным подчеркиванием доступны внутри класса, но извне доступны только при обращении к полю вида _
class Foo(object):
def __init__(self):
self.__bar = 42
Foo().__bar
AttributeError: 'Foo' object has no attribute '__bar'
Foo()._Foo__bar
42
В целом, джентльменское соглашение Python-программистов подразумевает (простое именование для приватных переменных или использование одинарного подчеркивания для переменных, которые очень нежелательно вытаскивать за пределы класса) + использование методов для доступа к переменным
class Stack(object):
def __init__(self):
self._storage = []
def push(self, value):
self._storage.append(value)
Интроспекция
Анализ метаданных классов во время выполнения.
Переменные
При вызове функции dir() без аргументов она возвращает список атрибутов (включая функции), доступных в локальной области видимости.
local_variables: list = dir()
locals() возвращает словарь текущей локальной таблицы символов (атрибут __dict__). locals() эквивалентна vars() без аргумента.
local_vars: dict = locals()
globals() возвращает словарь глобальной таблицы символов
global_variables: dict = globals()
print(local_variables)
print(local_vars)
print(global_variables)
['In', 'Out', '_', '__', '___', '__annotations__', '__builtin__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', '__vsc_ipynb_file__', '_dh', '_i', '_i1', '_ih', '_ii', '_iii', '_oh', 'exit', 'get_ipython', 'quit']
{'__name__': '__main__', '__doc__': 'Automatically created module for IPython interactive environment', '__package__': None, '__loader__': None, '__spec__': None, '__builtin__': <module 'builtins' (built-in)>, '__builtins__': <module 'builtins' (built-in)>, '_ih': ['', 'local_variables: list = dir()', 'local_vars: dict = locals()', 'global_variables: dict = globals()\n\nprint(local_variables)\nprint(local_vars)\nprint(global_variables)'], '_oh': {}, '_dh': [WindowsPath('c:/Works/amaargiru/pycore')], 'In': ['', 'local_variables: list = dir()', 'local_vars: dict = locals()', 'global_variables: dict = globals()\n\nprint(local_variables)\nprint(local_vars)\nprint(global_variables)'], 'Out': {}, 'get_ipython': <bound method InteractiveShell.get_ipython of <ipykernel.zmqshell.ZMQInteractiveShell object at 0x000002B22DC22260>>, 'exit': <IPython.core.autocall.ZMQExitAutocall object at 0x000002B22DC22D10>, 'quit': <IPython.core.autocall.ZMQExitAutocall object at 0x000002B22DC22D10>, '_': '', '__': '', '___': '', '__vsc_ipynb_file__': 'c:\\Works\\amaargiru\\pycore\\05_language_skeleton.ipynb', '_i': 'local_vars: dict = locals()', '_ii': 'local_variables: list = dir()', '_iii': '', '_i1': 'local_variables: list = dir()', '__annotations__': {'local_variables': <class 'list'>, 'local_vars': <class 'dict'>, 'global_variables': <class 'dict'>}, 'local_variables': ['In', 'Out', '_', '__', '___', '__annotations__', '__builtin__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', '__vsc_ipynb_file__', '_dh', '_i', '_i1', '_ih', '_ii', '_iii', '_oh', 'exit', 'get_ipython', 'quit'], '_i2': 'local_vars: dict = locals()', 'local_vars': {...}, '_i3': 'global_variables: dict = globals()\n\nprint(local_variables)\nprint(local_vars)\nprint(global_variables)', 'global_variables': {...}}
{'__name__': '__main__', '__doc__': 'Automatically created module for IPython interactive environment', '__package__': None, '__loader__': None, '__spec__': None, '__builtin__': <module 'builtins' (built-in)>, '__builtins__': <module 'builtins' (built-in)>, '_ih': ['', 'local_variables: list = dir()', 'local_vars: dict = locals()', 'global_variables: dict = globals()\n\nprint(local_variables)\nprint(local_vars)\nprint(global_variables)'], '_oh': {}, '_dh': [WindowsPath('c:/Works/amaargiru/pycore')], 'In': ['', 'local_variables: list = dir()', 'local_vars: dict = locals()', 'global_variables: dict = globals()\n\nprint(local_variables)\nprint(local_vars)\nprint(global_variables)'], 'Out': {}, 'get_ipython': <bound method InteractiveShell.get_ipython of <ipykernel.zmqshell.ZMQInteractiveShell object at 0x000002B22DC22260>>, 'exit': <IPython.core.autocall.ZMQExitAutocall object at 0x000002B22DC22D10>, 'quit': <IPython.core.autocall.ZMQExitAutocall object at 0x000002B22DC22D10>, '_': '', '__': '', '___': '', '__vsc_ipynb_file__': 'c:\\Works\\amaargiru\\pycore\\05_language_skeleton.ipynb', '_i': 'local_vars: dict = locals()', '_ii': 'local_variables: list = dir()', '_iii': '', '_i1': 'local_variables: list = dir()', '__annotations__': {'local_variables': <class 'list'>, 'local_vars': <class 'dict'>, 'global_variables': <class 'dict'>}, 'local_variables': ['In', 'Out', '_', '__', '___', '__annotations__', '__builtin__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', '__vsc_ipynb_file__', '_dh', '_i', '_i1', '_ih', '_ii', '_iii', '_oh', 'exit', 'get_ipython', 'quit'], '_i2': 'local_vars: dict = locals()', 'local_vars': {...}, '_i3': 'global_variables: dict = globals()\n\nprint(local_variables)\nprint(local_vars)\nprint(global_variables)', 'global_variables': {...}}
Дабы совсем уж не углубляться в дебри интроспекции (суть, думаю, вы уже уловили), давайте просто перечислим возможности, предоставляемые ею относительно атрибутов и параметров.
Атрибуты
l: list = dir(object) # Имена атрибутов объекта (включая методы)
d: dict = vars(object) # Возвращает object.__dict__.
value = getattr(object, 'attr_name') # Raises AttributeError if attribute is missing.
b: bool = hasattr(object, 'attr_name') # Checks if getattr() raises an AttributeError.
setattr(object, 'attr_name', value) # Only works on objects with '__dict__' attribute.
delattr(object, 'attr_name') # Same. Also `del <object>.<attr_name>`.
Parameters
GIL
Global Interpreter Lock — особенность интерпретатора, когда одновременно может исполняться только один тред, остальные треды в это время простаивают.
GIL позволяет безопасно согласовывать изменения данных. Без этого, например, если один тред удалит все элементы из списка, а второй начнет итерацию по нему, произойдет ошибка. Аналогично, сборщик мусора может начать некорректно подсчитывать ссылки. Проблему можно решить, установив блокировки на все разделяемые структуры данных, но это привнесло бы дополнительные сложности: оверхед по коду, потерю производительности, возможные deadlocks. GIL позволяет осуществлять простую интеграцию C-библиотек, которые зачастую тоже не потокобезопасны, а также обеспечивает быструю работу однопоточных скриптов.
GIL работает так: на каждый тред выделяется некоторый квант времени. Он измеряется в машинных единицах “тиках” и по умолчанию равен 100. Как только на тред было потрачено 100 тиков, интерпретатор бросает этот тред и переключается на второй, тратит 100 тактов на него, затем третий, и так по кругу. Этот алгоритм гарантирует, что всем тредам будет выделено ресурсов поровну.
Проблема в том, что из-за GIL далеко не все задачи могут быть решены в тредах. Напротив, их использование чаще всего снижает быстродействие программы. С использованием тредов требуется следить за доступом к общим ресурсам: словарям, файлам, соединением к БД.
Как обойти ограничения, накладываемые GIL?
Вариант 1 — воспользоваться штатной возможностью отключения GIL в версиях Python >= 3.13.
Вариант 2 — использовать альтернативные интерпретаторы Python, например PyPy.
Вариант 3 — уход от многопоточности в сторону мультипроцессности, используя модуль multiprocessing.
Что такое GIL? Что в нём полезного?
GIL означает Global Interpreter Lock (глобальная блокировка интерпретатора). Это мьютекс, используемый для ограничения доступа к объектам Python и помогающий эффективно синхронизировать потоки, избегая тупиковых ситуаций. GIL гарантирует, что только один из ваших потоков может выполняться в любой момент времени. Поток получает GIL, выполняет небольшую работу, а затем передает GIL следующему потоку. GIL помогает достичь многозадачности (а не параллельных вычислений).
GIL замедляет работу, но упрощает разработку кода и интеграцию с C-библиотеками.
В Python 3.13 был внедрен PEP 703 и появился флаг --disable-gil, позволяющий отключать GIL.
*args, *kwargs,
Выражения *args и **kwargs объявляют в сигнатуре функции. Они означают, что внутри функции будут доступны переменные с именами args и kwargs (без звездочек).
args – это кортеж, который накапливает позиционные аргументы. kwargs – словарь именованных аргументов, где ключ – имя параметра, значение – значение параметра. Вместо args и kwargs можно использовать другие имена (функция всё равно «поймёт», что от неё хотят, благодаря звездочке и двойной звездочке), но эта практика мало распространена.
Если в функцию не передано никаких параметров, переменные будут соответственно равны пустому кортежу и пустому словарю, а не None.
Оператор «звёздочка» применяется для распаковки элементов контейнера.
Вот, например, так выглядит результат включения нераспакованного списка в другой список:
a = [1, 2, 3]
b = [a, 4, 5, 6]
print(b)
[[1, 2, 3], 4, 5, 6]
А вот пример с операцией распаковки (обратите внимание на звёздочку во второй строке кода):
a = [1, 2, 3]
b = [*a, 4, 5, 6]
print(b)
[1, 2, 3, 4, 5, 6]
Лямбда-функция
Лямбда-функцию полезны, когда нам ненадолго требуется несложная безымянная функция (например, на входе reduce, filter или sorted). Лямбда-функции не резервируют имени в пространстве имен.
# Обычная функция
def add(a, b):
return a + b
# Лямбда
lambda_add = lambda a, b: a + b
print(add(2, 2), lambda_add(2, 2))
4 4
numbers = [1, 2, 3, 4, 5, 6]
even_numbers = list(filter(lambda x: x % 2 == 0, numbers))
print(even_numbers)
[2, 4, 6]
Лямбды в Питоне могут состоять только из одного выражения (что помогает избежать оператора return), но, заключив выражение в скобки, при желании можно оформить тело лямбды в несколько строк. Кроме return, внутри лямбды запрещено использовать и if, но тернарный оператор разрешён.
is_even = lambda x: "Even" if x % 2 == 0 else "Odd"
print(is_even(32768))
Even
Что такое лямбда-функция?
Лямбда-функции применимы там, где ненадолго нужна несложная безымянная функция (например, на входе reduce() или filter()).
Operator
Module of functions that provide the functionality of operators.
import operator as op
<el> = op.add/sub/mul/truediv/floordiv/mod(<el>, <el>) # +, -, *, /, //, %
<int/set> = op.and_/or_/xor(<int/set>, <int/set>) # &, |, ^
<bool> = op.eq/ne/lt/le/gt/ge(<sortable>, <sortable>) # ==, !=, <, <=, >, >=
<func> = op.itemgetter/attrgetter/methodcaller(<obj>) # [index/key], .name, .name()
elementwise_sum = map(op.add, list_a, list_b)
sorted_by_second = sorted(<collection>, key=op.itemgetter(1))
sorted_by_both = sorted(<collection>, key=op.itemgetter(1, 0))
product_of_elems = functools.reduce(op.mul, <collection>)
union_of_sets = functools.reduce(op.or_, <coll_of_sets>)
first_element = op.methodcaller('pop', 0)(<list>)
Binary operators require objects to have and(), or(), xor() and invert() special methods, unlike logical operators that work on all types of objects.
Also: `'<bool> = <bool> &|^ <bool>'` and `'<int> = <bool> &|^ <int>'`.
6. Многопоточность и многозадачность
Многопоточность
Процесс — приложение, которому выделена область памяти, недоступная другим приложениям. Поток — наименьшая сущность, которая может управляться напрямую операционной системой. У потоков нет своей памяти, они пользуются памятью создавшего их процесса. Потоки ассоциированы с создавшим их процессом. С каждым процессом всегда ассоциирован по меньшей мере один поток, обычно называемый главным.
API модулей threading и multiprocessing похожи.
Вкратце, GIL — ограничение, не позволяющее Python-процессу исполнять более одной команды байт-кода в каждый момент времени. GIL можно обойти при помощи многопроцессного подхода, т. к. у каждого процесса будет своя GIL.
Многопоточность реализуется модулем Threading. Это нативные Posix-треды, такие треды исполняются операционной системой, а не виртуальной машиной.
В чем отличие тредов от мультипроцессинга?
Главное отличие в разделении памяти. Процессы независимы друг от друга, имеют раздельные адресные пространства, идентификаторы, ресурсы. Треды исполняются в совместном адресном пространстве, имеют общий доступ к памяти, переменным, загруженным модулям.
Какие задачи хорошо параллелятся, какие плохо?
Те задачи, которые порождают долгий IO. Когда тред упирается в ожидание сокета или диска, интерпретатор бросает этот тред и стартует следующий. Это значит, не будет простоя из-за ожидания. Наоборот, если ходить в сеть в одном треде (в цикле), то каждый раз придется ждать ответа.
Однако, если затем в треде обрабатывает полученные данные, то выполняться будет только он один. Это не только не даст прироста в скорости, но и замедлит программу из-за переключения на другие треды.
Короткий ответ: хорошо ложатся на треды задачи по работе с сетью. Например, выкачать данные со ста разных ссылок. Полученные данные обрабатывайте вне тредов.
Нужно посчитать 100 уравнений. Делать это в тредах или нет?
Нет, потому что в этой задаче нет ввода-вывода. Интерпретатор только будет тратить лишнее время на переключение тредов. Сложные математические задачи лучше выносить в отдельные процессы, либо использовать фреймворк для распределенных задач Celery, либо подключать как C-библиотеки.
Понимание что такое heap dump и thread dump.
Понимание многопоточности, способов ей управлять и проблем, с этим связанных (синхронизации, локи, race condition и т.д.);
-
Многопоточность — вариант реализации вычислений, при котором для решения некоторой прикладной задачи запускаются и выполняются несколько независимых потоков вычислений, причём выполнение происходит одновременно или псевдоодновременно. В операционных системах, где термины "поток" и "процесс" различаются, под "потоком" понимают именно поток выполнения (ресурсами же владеет сущность, называемая "процессом"). Обычно применяется для распараллеливания вычислений на несколько вычислителей (процессоров и ядер процессора).
-
Многопроцессность — вариант реализации вычислений, когда для решения некоторой прикладной задачи запускается несколько независимых процессов. В системах, где под процессом понимается сущность, владеющая ресурсами (памятью, открытыми файлами, сетевыми подключениями), несколько процессов запускаются с целью повышения отказоустойчивости приложения, а также с целью повышения безопасности. Т.к. ОС выполняет разделение памяти и прочих ресурсов именно между процессами (в то время как потоки работают в едином адресном пространстве), то а) внезапно упавший (читай — убитый ОС) процесс не уронит остальные; б) если в процессе начал выполняться чужеродный код (например, из-за RCE уязвимости), то он не получит доступ к содержимому памяти в других процессах. Многопроцессность сегодня можно увидеть в браузерах, когда отдельные вкладки выполняются в разных процессах, и упавшая вкладка (из-за js или из-за кривого плагина) тянет за собой не весь браузер, а только себя или еще пару вкладок.
# Однопоточное приложение
import time
COUNT = 100_000_000
def countdown(n):
while n > 0:
n -= 1
start = time.time()
countdown(COUNT)
end = time.time()
print("Count time", end - start)
Count time 3.81453800201416
# Многопоточное приложение, время выполнения будет больше, чем у однопоточного, т. к. добавятся временные затраты на переключение потоков
import time
from threading import Thread
COUNT = 100_000_000
def countdown(n):
while n > 0:
n -= 1
t1 = Thread(target=countdown, args=(COUNT//2,))
t2 = Thread(target=countdown, args=(COUNT//2,))
start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()
print("Count time", end - start)
Count time 3.8378489017486572
# Многопроцессорное приложение
import time
import multiprocessing as mp
COUNT = 100_000_000
def countdown(n):
while n > 0:
n -= 1
if __name__ == '__main__':
pool = mp.Pool()
start = time.time()
pool.apply_async(countdown, args=(COUNT // 2,))
pool.apply_async(countdown, args=(COUNT // 2,))
pool.close()
pool.join()
end = time.time()
print("Count time", end - start)
Count time 2.0029137134552
Threading
CPython interpreter can only run a single thread at a time. That is why using multiple threads won't result in a faster execution, unless at least one of the threads contains an I/O operation.
from threading import Thread, RLock, Semaphore, Event, Barrier
from concurrent.futures import ThreadPoolExecutor
Thread
<Thread> = Thread(target=<function>) # Use `args=<collection>` to set the arguments.
<Thread>.start() # Starts the thread.
<bool> = <Thread>.is_alive() # Checks if the thread has finished executing.
<Thread>.join() # Waits for the thread to finish.
Use 'kwargs=<dict>' to pass keyword arguments to the function.
Use 'daemon=True', or the program will not be able to exit while the thread is alive.**
Lock
<lock> = RLock() # Lock that can only be released by the owner.
<lock>.acquire() # Waits for the lock to be available.
<lock>.release() # Makes the lock available again.
Or:
with <lock>: # Enters the block by calling acquire(),
... # and exits it with release().
Semaphore, Event, Barrier
<Semaphore> = Semaphore(value=1) # Lock that can be acquired by 'value' threads.
<Event> = Event() # Method wait() blocks until set() is called.
<Barrier> = Barrier(n_times) # Wait() blocks until it's called n_times.
Thread Pool Executor
Object that manages thread execution. An object with the same interface called ProcessPoolExecutor provides true parallelism by running a separate interpreter in each process. All arguments must be pickable.
<Exec> = ThreadPoolExecutor(max_workers=None) # Or: `with ThreadPoolExecutor() as <name>: …`
<Exec>.shutdown(wait=True) # Blocks until all threads finish executing.
<iter> = <Exec>.map(<func>, <args_1>, ...) # A multithreaded and non-lazy map().
<Futr> = <Exec>.submit(<func>, <arg_1>, ...) # Starts a thread and returns its Future object.
<bool> = <Futr>.done() # Checks if the thread has finished executing.
<obj> = <Futr>.result() # Waits for thread to finish and returns result.
asyncio
https://realpython.com/async-io-python/
Преимущество asyncio — гранулярность. Поток будет приостановлен не в момент, более или менее правильно намеченный ОС в соответствии со своими алгоритмами планирования, а в явно помеченной программистом точке.
Сопрограмма asyncio — обычная функция Python, наделенная одной сверхспособностью: приостанавливаться, встретив операцию, для выполнения которой нужно существенное время. Для создания и приостановки сопрограммы нужно использовать ключевые слова async и await. async определяет сопрограмму, а await приостанавливает ее на время выполнения длительной операции.
Важный момент — сопрограмма не выполняется при прямом вызове. Вместо этого возвращается объект сопрограммы, который будет выполнен позже. Чтобы выполнить сопрограмму, мы должны явно передать ее циклу событий.
В JavaScript async / await сделаны жадными как Promise. При вызове async функции автоматически создается задача и отправляется в очередь на исполнение в event loop. await, в свою очередь, просто ждёт результат.
В питоне асинхронщину задизайнили иначе — лениво.
Вызов async функции возвращает объект — корутину, — которая ни чего не делает.
asyncio.run() создаёт event loop, запускает (корневую) корутину и блокирует поток до получения результата.
await запускает корутину изнутри другой корутины в текущем event loop и ждёт результат.
Для запуска корутины без ожидания (как это делает Promise) используется asyncio.create_task(coro). Либо asyncio.gather(*aws), если надо запустить сразу несколько. Нужно только следить, чтобы ссылка на возвращаемое значение сохранялась до конца вычисления, иначе его пожрет GC и все оборвется на самом интересном месте (промис бы отработал до конца не смотря ни на что).
В JS только один event loop, поэтому было вполне разумно закопать его внутрь promise / async / await как деталь реализации, упростив работу прикладному программисту. В питоне отзеркалили более ранний вариант корутин на генераторах, дали возможность использовать разные event loop и выставили все кишки наружу.
Простейший пример, одновременный запуск двух функций, последовательное выполнение которых в "синхронном" мире заняло бы 2 секунды, но в "асинхронном" мире они выполняются приблизительно за 1 секунду.
Без asyncio, просто две функции с имитацией некоторого полезного вычисления и последующего ожидания:
import time
from time import perf_counter
def fun1():
sumi: int = 0
for i in range(1000_000):
sumi += i
time.sleep(1)
print(f'Sum: {sumi}')
def fun2():
producti: int = 1
for i in range(1, 25):
producti = i * producti
time.sleep(1)
print(f'Product: {producti}')
start_time = perf_counter()
fun1()
fun2()
duration = perf_counter() - start_time
print(f'Total duration: {duration} seconds')
Sum: 499999500000
Product: 620448401733239439360000
Total duration: 2.047113300068304 seconds
С asyncio (синтаксис вызова amain() изменен в связи с некоторыми особенностями использования asyncio в Jupiter Notebook):
import asyncio
from time import perf_counter
async def afun1():
sumi: int = 0
for i in range(1000_000):
sumi += i
await asyncio.sleep(1)
print(f'Sum: {sumi}')
async def afun2():
producti: int = 1
for i in range(1, 25):
producti = i * producti
await asyncio.sleep(1)
print(f'Product: {producti}')
async def amain():
task1 = asyncio.create_task(afun1())
task2 = asyncio.create_task(afun2())
await task1
await task2
start_time = perf_counter()
# asyncio.run(amain())
await amain()
duration = perf_counter() - start_time
print(f'Total duration: {duration} seconds')
Sum: 499999500000
Product: 620448401733239439360000
Total duration: 1.0424554999917746 seconds
Пример запуска на исполнение двух асинхронных периодических задач:
import asyncio
from datetime import datetime
async def periodic_fun1(a, b):
while True:
await asyncio.sleep(1)
print(f'periodic_fun1 complete with result {a + b}')
async def periodic_fun2(a, b):
while True:
await asyncio.sleep(0.5)
print(f'periodic_fun2 complete with result {a - b}')
async def main():
start_time = datetime.now()
task1 = asyncio.create_task(periodic_fun1(3, 2))
task2 = asyncio.create_task(periodic_fun2(3, 2))
await asyncio.sleep(10)
task1.cancel()
task2.cancel()
duration_time = datetime.now() - start_time
print(f'Total duration time: {duration_time}')
if __name__ == '__main__':
# asyncio.run(main())
await amain() # https://stackoverflow.com/questions/55409641/asyncio-run-cannot-be-called-from-a-running-event-loop-when-using-jupyter-no
Sum: 499999500000
Product: 620448401733239439360000
Пример накопления данных от двух асинхронных периодических задач в одной разделяемой структуре данных asyncio.Queue():
import asyncio
import random
from datetime import datetime
# Пример накопления данных от двух асинхронных периодических задач в одной разделяемой структуре данных asyncio.Queue().
async def produce_small_random(queue):
while True:
await asyncio.sleep(0.5)
r: int = random.randint(1, 9)
print(f'Small random produced {r}')
await queue.put(r)
async def produce_big_random(queue):
while True:
await asyncio.sleep(1)
r: int = random.randint(100, 999)
print(f'Big random produced {r}')
await queue.put(r)
async def main():
q = asyncio.Queue()
start_time = datetime.now()
small_random_task = asyncio.create_task(produce_small_random(q))
big_random_task = asyncio.create_task(produce_big_random(q))
await asyncio.sleep(10)
small_random_task.cancel()
big_random_task.cancel()
# Dumping asyncio.queue into list
randl: list[int] = []
while q.qsize() > 0:
randl.append(await q.get())
q.task_done()
duration_time = datetime.now() - start_time
print(f'Total queue = {randl}')
print(f'Total duration time: {duration_time}')
if __name__ == '__main__':
# asyncio.run(main())
await main() # https://stackoverflow.com/questions/55409641/asyncio-run-cannot-be-called-from-a-running-event-loop-when-using-jupyter-no
Small random produced 1
Big random produced 929
Small random produced 4
Small random produced 3
Big random produced 967
Small random produced 8
Small random produced 1
Big random produced 622
Small random produced 9
Small random produced 7
Big random produced 891
Small random produced 5
Small random produced 5
Big random produced 820
Small random produced 8
Small random produced 5
Big random produced 771
Small random produced 5
Small random produced 6
Big random produced 289
Small random produced 6
Small random produced 8
Big random produced 873
Small random produced 8
Small random produced 3
Big random produced 127
Small random produced 6
Small random produced 3
Total queue = [1, 929, 4, 3, 967, 8, 1, 622, 9, 7, 891, 5, 5, 820, 8, 5, 771, 5, 6, 289, 6, 8, 873, 8, 3, 127, 6, 3]
Total duration time: 0:00:10.001941
7. Популярные библиотеки
Логгирование
import pathlib
import sys
import logging
from logging.handlers import RotatingFileHandler
from colorlog import ColoredFormatter
class TestLogger:
@staticmethod
def get_logger(path_to_log_file: str, max_file_size: int, max_file_count: int) -> logging.Logger:
logger = logging.getLogger("sample_logger")
logger.setLevel(logging.DEBUG)
# Форматирование при выводе в файл
flog_formatter = logging.Formatter("%(asctime)s.%(msecs)03d %(filename)-24s %(levelname)-8s %(message)s",
datefmt="%a, %d %b %Y %H:%M:%S")
file_handler = RotatingFileHandler(filename=path_to_log_file, mode="a", maxBytes=max_file_size,
backupCount=max_file_count, encoding="utf-8", delay=False)
file_handler.setFormatter(flog_formatter)
logger.addHandler(file_handler)
# Форматирование при выводе в консоль
clog_formatter = ColoredFormatter("%(asctime)s.%(msecs)03d %(filename)-24s :%(lineno)4d "
"%(log_color)s%(levelname)-8s %(message)s%(reset)s",
datefmt="%a, %d %b %Y %H:%M:%S")
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(clog_formatter)
logger.addHandler(console_handler)
return logger
log_file_max_size: int = 25 * 1024 ** 2 # Максимальный размер одного файла логов
log_file_max_count: int = 10 # Максимальное количество файлов логов
log_file_path: str = "logs/sample_logger.log"
try:
path = pathlib.Path(log_file_path) # Создаем путь к файлу логов, если он не существует
path.parent.mkdir(parents=True, exist_ok=True)
logger = TestLogger.get_logger(log_file_path, log_file_max_size, log_file_max_count)
except Exception as err:
print(f"Ошибка при попытке создания файла лога: {str(err)}")
sys.exit() # Аварийный выход
logger.debug("Debug message")
logger.info("Hello, world!")
logger.error("Error!")
Thu, 15 Sep 2022 17:37:36.754 1628851721.py : 44 [37mDEBUG Debug message[0m
Thu, 15 Sep 2022 17:37:36.755 1628851721.py : 45 [32mINFO Hello, world![0m
Thu, 15 Sep 2022 17:37:36.756 1628851721.py : 46 [31mERROR Error![0m
Профилирование
Stopwatch
from time import time
start_time = time()
j: int = 0
for i in range(10_000_000): # Long operation
j = i ** 2
duration = time() - start_time
print(f"{duration} seconds")
2.2923033237457275 seconds
High performance
from time import perf_counter
start_time = perf_counter()
j: int = 0
for i in range(10_000_000): # Long operation
j = i ** 2
duration = perf_counter() - start_time
print(f"{duration} seconds")
2.2668115999549627 seconds
timeit
Try to avoid a number of common traps for measuring execution times
from timeit import timeit
def long_pow():
j: int = 0
for i in range(1_000_000): # Long operation
j = i ** 2
timeit("long_pow()", number=10, globals=globals(), setup='pass')
1.8498943001031876
Call Graph
Создает PNG изображение графа вызовов с подсвеченными узкими местами
from pycallgraph3 import PyCallGraph
from pycallgraph3.output import GraphvizOutput
def long_pow():
j: int = 0
for i in range(1000_000): # Long operation
j = i ** 2
def short_pow():
j: int = 0
for i in range(1000): # Short operation
j = i ** 2
with PyCallGraph(output=GraphvizOutput()):
# Code to be profiled
long_pow()
short_pow()
# This will generate a file called pycallgraph3.png
Random
import random
rf: float = random.random() # A float inside [0, 1)
print(f"Single float random: {rf}")
ri: int = random.randint(1, 10) # An int inside [from, to]
print(f"Single int random: {ri}")
rb = random.randbytes(10)
print(f"Random bytes: {rb}")
rc: str = random.choice(["Alice", "Bob", "Maggie", "Madhuri Dixit"])
print(f"Random choice: {rc}")
rs: str = random.sample([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 5)
print(f"Random list without duplicates: {rs}")
a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
print(f"List before shuffle: {a}")
random.shuffle(a)
print(f"List after shuffle: {a}")
Single float random: 0.9024807633898538
Single int random: 7
Random bytes: b'>\xe0^\x16PX\xf8E\xf8\x98'
Random choice: Bob
Random list without duplicates: [5, 10, 3, 6, 1]
List before shuffle: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
List after shuffle: [10, 4, 6, 5, 1, 8, 3, 9, 7, 2]
Input
Reads a line from user input or pipe if present.
Trailing newline gets stripped. Prompt string is printed to the standard output before reading input. Raises EOFError when user hits EOF (ctrl-d/ctrl-z⏎) or input stream gets exhausted.
Command Line Arguments
import sys
scripts_path = sys.argv[0]
arguments = sys.argv[1:]
Argument Parser
from argparse import ArgumentParser, FileType
p = ArgumentParser(description=<str>)
p.add_argument('-<short_name>', '--<name>', action='store_true') # Flag.
p.add_argument('-<short_name>', '--<name>', type=<type>) # Option.
p.add_argument('<name>', type=<type>, nargs=1) # First argument.
p.add_argument('<name>', type=<type>, nargs='+') # Remaining arguments.
p.add_argument('<name>', type=<type>, nargs='*') # Optional arguments.
args = p.parse_args() # Exits on error.
value = args.<name>
Use `'help=<str>'` to set argument description.
Use `'default=<el>'` to set the default value.
Use `'type=FileType(<mode>)'` for files.
print(<el_1>, ..., sep=' ', end='\n', file=sys.stdout, flush=False)
Use `'file=sys.stderr'` for messages about errors.
Use `'flush=True'` to forcibly flush the stream.
Pretty Print
from pprint import pprint
pprint(<collection>, width=80, depth=None, compact=False, sort_dicts=True)
Levels deeper than 'depth' get replaced by '...'.
Качественная хеш-функция удовлетворяет (приближенно) условию простого равномерного хеширования: для каждого ключа, независимо от хеширования других ключей, равновероятно помещение его в любую из m ячеек.
Шифрование и дешифрование
# pip install pycryptodomex
import hashlib
from Cryptodome import Random
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import pad
from Cryptodome.Util.Padding import unpad
def encrypt_data(password: str, raw_data: bytes) -> bytes:
res = bytes()
try:
key = hashlib.sha256(password.encode()).digest()
align_raw = pad(raw_data, AES.block_size)
iv = Random.new().read(AES.block_size)
cipher = AES.new(key, AES.MODE_CBC, iv)
ciphered_data = cipher.encrypt(align_raw)
res = iv + ciphered_data
except Exception as e:
print(f"Encrypt error: {str(e)}")
return res
def decrypt_data(password: str, encrypted_data: bytes) -> bytes:
res = bytes()
try:
key = hashlib.sha256(password.encode()).digest()
iv = encrypted_data[:AES.block_size]
ciphered_data = encrypted_data[AES.block_size:]
cipher = AES.new(key, AES.MODE_CBC, iv)
decrypt_data = cipher.decrypt(ciphered_data)
res = unpad(decrypt_data, AES.block_size)
except Exception as e:
print(f"Decrypt error: {str(e)}")
return res
def encrypt_file(src_file: str, dst_file: str, password: str) -> bool:
try:
with open(src_file, "rb") as reader, open(dst_file, "wb") as writer:
data = reader.read()
data_enc = encrypt_data(password, data)
writer.write(data_enc)
writer.flush()
print(f"{src_file} encrypted into {dst_file}")
return True
except Exception as e:
print(f"Encrypt_file error: {str(e)}")
return False
def decrypt_file(src_file: str, dst_file: str, password: str) -> bool:
try:
with open(src_file, "rb") as reader, open(dst_file, "wb") as writer:
data = reader.read()
data_decrypt = decrypt_data(password, data)
writer.write(data_decrypt)
writer.flush()
print(f"{src_file} decrypted into {dst_file}")
return True
except Exception as e:
print(f"Decrypt file error: {str(e)}")
return False
if __name__ == '__main__':
mes: bytes = bytes("A am the Message", "utf-8")
passw: str = "h3AC3TsU8TECvyCqd5Q5WUag5uXLjct2"
print(f"Original message: {mes}")
# Encrypt message
enc: bytes = encrypt_data(passw, mes)
print(f"Encrypted message: {enc}")
# Decrypt message
dec: bytes = decrypt_data(passw, enc)
print(f"Decrypted message: {dec}")
Original message: b'A am the Message'
Encrypted message: b' E\x92\xbeH\x87\xdde\t\xd3\x9ap\x0cO\xc3\xf8\x84\xc7~\x1c\x90\xcd\x9a\xd3\x1bNd\xccDt\x1b\xfcZ\x91\xb5\xd78\x85\x91R\x1e]3\x9c\xec\xcbC\xd8'
Decrypted message: b'A am the Message'
Тестирование, pytest
Для работы с pytest внутри Jupiter notebook воспользуемся инструментом ipytest
# pip install -U ipytest
import ipytest
ipytest.autoconfig()
%%ipytest
import ipytest
def test_my_func():
assert my_func(0) == 0
assert my_func(1) == 0
assert my_func(2) == 2
assert my_func(3) == 2
def my_func(x):
return x // 2 * 2
[32m.[0m[32m [100%][0m
[32m[32m[1m1 passed[0m[32m in 0.01s[0m[0m
%%ipytest
import pytest
@pytest.mark.parametrize('input,expected', [
(0, 0),
(1, 0),
(2, 2),
(3, 2),
])
def test_parametrized(input, expected):
assert my_func(input) == expected
@pytest.fixture
def my_fixture():
return 42
def test_fixture(my_fixture):
assert my_fixture == 42
[32m.[0m[32m.[0m[32m.[0m[32m.[0m[32m.[0m[32m [100%][0m
[32m[32m[1m5 passed[0m[32m in 0.01s[0m[0m
Моки, стабы
Тестовый двойник — термин, описывающий все виды фальшивых (fake) зависимостей, непригодных к использованию в конечном продукте (non-production-ready). Такая зависимость выглядит и ведет себя как ее аналог, предназначенный для production, но на самом деле является упрощенной версией, которая снижает сложность и облегчает тестирование.
Зависимости бывают пяти типов, но для простоты можно ограничиться двумя: моки (mock) и стабы (stub).
Моки помогают имитировать и изучать исходящие (outcoming) взаимодействия. То есть вызовы, совершаемые тестируемой системой (SUT) к ее зависимостям для изменения их состояния.
Стабы помогают имитировать входящие (incoming) взаимодействия. То есть вызовы, совершаемые SUT к ее зависимостям для получения входных данных.
Понятие моков и стабов связано с принципом command-query separation (CQS). Принцип CQS гласит, что каждый метод должен быть либо командой, либо запросом, но не обоими.
Команды — это методы, которые вызывают побочные эффекты и не возвращают никакого значения. Примеры побочных эффектов включают изменение состояния объекта, изменение файла в файловой системе и т. д.
Запросы, наоборот, не имеют побочных эффектов и возвращают значение.
Другими словами, задавая вопрос, вы не должны менять ответ. Код, который поддерживает такое четкое разделение, становится легче для чтения. Тестовые двойники, заменяющие команды, становятся моками, и, соответственно, тестовые двойники, заменяющие запросы, становятся стабами.
Не нужно проверять взаимодействия со стабами
Как уже упоминалось, стабы помогают только эмулировать входящие взаимодействия, а не изучать их. Из этого следует, что вы никогда не должны проверять взаимодействие со стабами. Вызов от SUT к стабу не является частью конечного результата, который выдает SUT. Такой вызов — это всего лишь средство для получения конечного результата; это деталь реализации. Проверка взаимодействий со стабами является распространенным анти-паттерном, который приводит к хрупким тестам. Единственный способ избежать хрупкости тестов — это заставить эти тесты проверять конечный результат (который в идеале должен иметь значение для специалиста в предметной области, а не для программиста), а не детали реализации.
Самодостаточность тестов
К тестам нужно относиться как к функциональному программированию: при отправке одних и тех же аргументов, мы должны получать ровно тот же результат. Тесты должны быть самодостаточны, нельзя обуславливать вызов некоторого теста предварительным вызовом какого-то другого теста.
8. Алгоритмы
FizzBuzz
Массивы (Array):
• Sliding window
• Two pointers
• Traversing from the right
• Precomputation
• Index as a hash key
• Traversing the array more than once
Строки (String):
• Common string algorithms:
• • Rabin Karp for efficient searching of substring using a rolling hash
• • KMP for efficient searching of substring
Матрицы (двумерные массивы) (Matrix)
Связные списки (Linked List):
• Sentinel/dummy nodes
• Two pointers
• Using space
• Elegant modification operations
Деревья и графы:
• Бинарные деревья (binary tree)
• Бинарные деревья поиска (BST):
• • Use recursion
• • Traversing by level
• • Summation of nodes
• Графы:
• • BFS
• • DFS
• Дейкстра
• • Флойд-Warshall
• • Topological sorting
• Heap
• • Mention of k
• Trie
• • Sometimes preprocessing a dictionary of words (given in a list) into a trie, will improve the efficiency of searching for a word of length k, among n words. Searching becomes O(k) instead of O(n).
Binary search
Сортировка:
• Пузырьковая
• Выбором
• Вставками
• Быстрая
• Слиянием
Hash Table
Stack
Queue
Reqursion
Паралеллизм
Divide and conquer
Greedy Algorithm
Dynamic Programming
Binary
Math
FizzBuzz
Напишите программу, которая выводит на экран числа от 1 до 100. Вместо чисел, кратных трем, программа должна выводить слово «Fizz», а вместо чисел, кратных пяти — слово «Buzz». Если число кратно и 3, и 5, то программа должна выводить слово «FizzBuzz».
n: int = 100
for i in range(1, n + 1):
if i % 15 == 0:
item = "FizzBuzz"
elif i % 5 == 0:
item = "Buzz"
elif i % 3 == 0:
item = "Fizz"
else:
item = i
print(item, end=" ")
1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBuzz 16 17 Fizz 19 Buzz Fizz 22 23 Fizz Buzz 26 Fizz 28 29 FizzBuzz 31 32 Fizz 34 Buzz Fizz 37 38 Fizz Buzz 41 Fizz 43 44 FizzBuzz 46 47 Fizz 49 Buzz Fizz 52 53 Fizz Buzz 56 Fizz 58 59 FizzBuzz 61 62 Fizz 64 Buzz Fizz 67 68 Fizz Buzz 71 Fizz 73 74 FizzBuzz 76 77 Fizz 79 Buzz Fizz 82 83 Fizz Buzz 86 Fizz 88 89 FizzBuzz 91 92 Fizz 94 Buzz Fizz 97 98 Fizz Buzz
О-о-о! Большое!
Нотация O — характеристика асимптотической сложности алгоритма без учета константы.
n! >> 2^n >> n^3 >> n^2 >> nlogn >> n >> logn >> 1
Пузырьковая сортировка (BubbleSort)
Простейший алгоритм сортировки, состоит из повторяющихся проходов по сортируемому массиву. В процессе каждого прохода элементы массива сравниваются попарно; элементы, не удовлетворяющие условию сортировки, меняются местами.
def bubblesort(arr):
for i in range(len(arr)):
for j in range(len(arr) - 1):
if arr[j] > arr[j+1]:
arr[j], arr[j+1] = arr[j+1], arr[j]
return arr
arr: list = [28, 64, 2, 1, 13, 0]
print(bubblesort(arr))
[0, 1, 2, 13, 28, 64]
Быстрая сортировка (QuickSort)
Идея алгоритма следующая:
- Выбирается опорный элемент; это в первом приближении может быть любой из элементов, например, значение из середины массива.
- Все элементы массива сравниваются с опорным и переставляются так, чтобы образовать новый массив, состоящий из двух последовательных сегментов — элементы меньшие опорного, равные опорному + большие опорного.
- Если длина сегментов больше 1, то рекурсивно выполнить сортировку и для них тоже.
def quicksort(arr):
less = []
equal = []
greater = []
if len(arr) > 1:
pivot = arr[0]
for x in arr:
if x < pivot:
less.append(x)
elif x == pivot:
equal.append(x)
elif x > pivot:
greater.append(x)
return quicksort(less) + equal + quicksort(greater)
else:
return arr
arr: list = [28, 64, 2, 1, 13, 0]
print(quicksort(arr))
[0, 1, 2, 13, 28, 64]
Сортировка слиянием (MergeSort)
Ключевым моментом сортировки слиянием является (как ни странно :) слияние двух массивов. При слиянии массивы сравниваются поэлементно и меньший элемент записывается в результирующий массив; после того, как достигнут конец одного из массивов, в результирующий массив переписывается "хвост" оставшегося массива.
Совместно со слиянием двух массивов используется рекурсивное разбиение сортируемого массива на сегменты меньшего размера.
def mergesort(arr):
if len(arr) < 2:
return arr
result, mid = [], int(len(arr)//2)
y = mergesort(arr[:mid])
z = mergesort(arr[mid:])
while (len(y) > 0) and (len(z) > 0):
if y[0] > z[0]:
result.append(z.pop(0))
else:
result.append(y.pop(0))
return result + y + z
arr: list = [28, 64, 2, 1, 13, 0]
print(mergesort(arr))
[0, 1, 2, 13, 28, 64]
Пирамидальная сортировка (HeapSort)
Превращаем массив в двоичное дерево за О(n) операций.
Раз за разом преобразуя дерево, получим отсортированный массив.
def heap_sort():
end = len(arr)
start = end // 2 - 1
for i in range(start, -1, -1):
heapify(end, i)
for i in range(end-1, 0, -1):
swap(i, 0)
heapify(i, 0)
def heapify(end,i):
l = 2 * i + 1
r = 2 * (i + 1)
max = i
if l < end and arr[i] < arr[l]:
max = l
if r < end and arr[max] < arr[r]:
max = r
if max != i:
swap(i, max)
heapify(end, max)
def swap(i, j):
arr[i], arr[j] = arr[j], arr[i]
arr: list = [28, 64, 2, 1, 13, 0]
heap_sort()
print(arr)
[0, 1, 2, 13, 28, 64]
Сортировка вставками (InsertionSort)
Каждый новый элемент заносится в выходную последовательность "индивидуально", т. е. каждый раз для добавления нового элемента приходится сдвигать часть массива. Алгоритм медленный, для ускорения вставки можно использовать бинарный поиск.
def insertionsort(arr):
for index in range(1, len(arr)):
currentvalue = arr[index]
position = index
while position > 0 and arr[position - 1] > currentvalue:
arr[position] = arr[position - 1]
position = position - 1
arr[position] = currentvalue
arr: list = [28, 64, 2, 1, 13, 0]
insertionsort(arr)
print(arr)
[0, 1, 2, 13, 28, 64]
Timsort
Комбинированный алгоритм сортировки, сочетающий сортировку вставками, сортировку слиянием и предположение, что в реальном мире данные часто уже частично упорядочены (поиск упорядоченных подмассивов). Стандарт для Python, Java, Swift.
Introsort
Комбинированный алгоритм сортировки, использует быструю сортировку, плюс, при превышении глубины рекурсии некоторой величины (например, логарифма от числа сортируемых элементов), переключается на пирамидальную сортировку. Стандарт для .NET.
Поразрядная сортировка (RadixSort)
Сортирует только сущности, которые можно разбить на "разряды", имеющие разный вес. Это могут быть, например, целые числа или строки. Соответственно, элементы сортируются поразрядно, начиная с разряда, имеющего максимальный вес.
def radixsort(arr: list):
n = len(str(max(arr)))
for k in range(n):
bucket_list=[[] for i in range(10)]
for i in arr:
bucket_list[i // (10**k) % 10].append(i)
arr = sum(bucket_list, []) # Flattening a list of lists to a list
return arr
arr: list = [28, 64, 2, 1, 13, 0]
print(radixsort(arr))
[0, 1, 2, 13, 28, 64]
Таблица сравнения методов сортировки
| Сортировка | Преимущество | Best | Avg | Worst | Mem | Stable | Paral |
|---|---|---|---|---|---|---|---|
| Пузырьковая (Bubble) |
Простейшая реализация | n | n^2 | n^2 | 1 | + | + |
| Быстрая (Quick) |
Хорошее быстродействие в среднем случае | n*logn | n*logn | n^2 | logn | +/- (depends) |
+ |
| Слиянием (Merge) |
Может работать со структурами, к которым возможен только последовательный доступ | n*logn | n*logn | n*logn | n (depends) |
+ | + |
| Пирамидальная (Heap) |
Предсказуемая производительность в наихудшем случае, рекомендуется для почти отсортированных данных | n*logn | n*logn | n*logn | 1 | - | - |
| Вставками (Insertion) |
Рекомендуется для почти отсортированных данных или для малого количества элементов | n | n^2 | n^2 | 1 | + | - |
| Timsort | Комбинированный алгоритм. Стандарт для Python, Java, Swift | n*logn | n*logn | n*logn | logn | - | - |
| Introsort | Комбинированный алгоритм. Стандарт для .Net | n*logn | n*logn | n*logn | logn | - | - |
| Поразрядная (Radix) |
Быстрая сортировка для целых чисел и строк | n*w | n*w | n*w | n+w | +/- (depends) |
+ |
Линейный поиск
Последовательный поиск элемента в неотсортированном массиве
def linear_search(arr, x):
for i in range(len(arr)):
if arr[i] == x:
return i
return None
arr: list = [28, 64, 2, 1, 13, 0]
print(linear_search(arr, 64))
1
Бинарный поиск
Работает только с отсортированными массивами. Берется значение из середины массива и сравнивается с искомой величиной. В зависимости от сравнения дальнейший рекурсивный поиск продолжается с середины либо левого, либо правого подмассива.
def binary_search(arr, x):
left, right = 0, len(arr) - 1
while left <= right:
middle = (left + right) // 2
if arr[middle] == x:
return middle
if arr[middle] < x:
left = middle + 1
elif arr[middle] > x:
right = middle - 1
arr: list = [0, 24, 64, 222, 1300, 2048]
print(binary_search(arr, 64))
2
Поиск в глубину (DFS)
Метод обхода графа. Depth-first search (DFS) можно чуть точнее перевести как "поиск в первую очередь в глубину". Соответственно, рекурсивный алгоритм поиска идет «вглубь» графа, насколько это возможно. Есть нерекурсивные варианты алгоритма, разгружающие стек вызовов.
# Using a Python dictionary to act as an adjacency list
graph = {
'5' : ['3','7'],
'3' : ['2', '4'],
'7' : ['8'],
'2' : [],
'4' : ['8'],
'8' : []
}
visited = set() # Set to keep track of visited nodes of graph
def dfs(visited, graph, node): # Function for DFS
if node not in visited:
print (node)
visited.add(node)
for neighbour in graph[node]:
dfs(visited, graph, neighbour)
dfs(visited, graph, '5')
5
3
2
4
8
7
Поиск в ширину (BFS)
В отличие от предыдущего варианта алгоритм Breadth-first search (BFS) перебирает в первую очередь вершины с одинаковым расстоянием от корня, и только потом идет «вглубь».
# https://codereview.stackexchange.com/questions/135156/bfs-implementation-in-python-3
import collections
def breadth_first_search(graph, root):
visited, queue = set(), collections.deque([root])
while queue:
vertex = queue.popleft()
for neighbour in graph[vertex]:
if neighbour not in visited:
visited.add(neighbour)
queue.append(neighbour)
if __name__ == '__main__':
graph = {0: [1, 2], 1: [2], 2: []}
breadth_first_search(graph, 0)
deque([])
Алгоритм Дейкстры
Находит кратчайшие пути от одной из вершин графа до всех остальных. Алгоритм работает только для графов без отрицательных рёбер.
# https://stackoverflow.com/questions/22897209/dijkstras-algorithm-in-python
nodes = ('A', 'B', 'C', 'D', 'E', 'F', 'G')
distances = {
'B': {'A': 5, 'D': 1, 'G': 2},
'A': {'B': 5, 'D': 3, 'E': 12, 'F' :5},
'D': {'B': 1, 'G': 1, 'E': 1, 'A': 3},
'G': {'B': 2, 'D': 1, 'C': 2},
'C': {'G': 2, 'E': 1, 'F': 16},
'E': {'A': 12, 'D': 1, 'C': 1, 'F': 2},
'F': {'A': 5, 'E': 2, 'C': 16}}
unvisited = {node: None for node in nodes} #using None as +inf
visited = {}
current = 'B'
currentDistance = 0
unvisited[current] = currentDistance
while True:
for neighbour, distance in distances[current].items():
if neighbour not in unvisited: continue
newDistance = currentDistance + distance
if unvisited[neighbour] is None or unvisited[neighbour] > newDistance:
unvisited[neighbour] = newDistance
visited[current] = currentDistance
del unvisited[current]
if not unvisited: break
candidates = [node for node in unvisited.items() if node[1]]
current, currentDistance = sorted(candidates, key = lambda x: x[1])[0]
print(visited)
{'B': 0, 'D': 1, 'E': 2, 'G': 2, 'C': 3, 'A': 4, 'F': 4}
Алгоритм Беллмана-Форда
Как и алгоритм Дейкстры, находит кратчайшие пути от одной из вершин графа до всех остальных, но, в отличие от первого, позволяет работать с графами с ребрами, имеющими отрицательный вес.
Таблица сравнения методов поиска
| Вид поиска | Структура данных | Avg | Worst | Mem |
|---|---|---|---|---|
| Линейный поиск | Массив | n | n | 1 |
| Бинарный поиск | Отсортированный массив | logn | n | 1 |
| Поиск в глубину (DFS) | Граф | V+E | V | |
| Поиск в ширину (BFS) | Граф | V+E | V | |
| Алгоритм Дейкстры | Граф | (V+E)logV | (V+E)logV | V |
| Алгоритм Беллмана-Форда | Граф | V*E | V*E | V |
P vs NP
Задачи класса P — реально вычислимые задачи (тезис Кобэма), решаются за полиномиальное время.
NP-полные задачи — не разрешимы за полиномиальное время, но могут быть сведены к задачам разрешимости (да/нет), которые, в свою очередь, решаются за полиномиальное время.
Разделяй и властвуй
Разделяй и властвуй (divide and conquer) — способ решения сложных задач путём рекурсивного разбиения решаемой задачи на две или более подзадачи того же типа, но меньшего размера, и комбинировании их решений для получения ответа к исходной задаче; разбиения выполняются до тех пор, пока все подзадачи не окажутся элементарными.
Динамическое программирование
Динамическое программирование — способ решения сложных задач путём разбиения их на более простые подзадачи. Он применим к задачам со структурой, выглядящей как набор перекрывающихся подзадач, сложность которых меньше исходной.
Ключевая идея: как правило, чтобы решить поставленную задачу, требуется решить отдельные части задачи (подзадачи), после чего объединить решения подзадач в одно общее решение. Часто многие из этих подзадач одинаковы. Подход динамического программирования состоит в том, чтобы решить каждую подзадачу только один раз, сократив тем самым количество вычислений. Это особенно полезно в случаях, когда число повторяющихся подзадач экспоненциально велико.ы
Жадные алгоритмы
Жадный алгоритм (greedy algorithm) — алгоритм, который на каждом шагу делает локально наилучший выбор в надежде, что итоговое решение будет оптимальным.
Как определить, даст ли жадный алгоритм оптимальное решение? В соответствии с теоремой Радо-Эдмондса, если система является матроидом, то для произвольной весовой функции градиентный алгоритм всегда находит точное решение задачи. Следовательно, если доказать, что объект является матроидом, то жадный алгоритм будет выдавать оптимальный вариант.
Жадные алгоритмы проще и быстрее алгоритмов на базе динамического программирования.
Различие между жадными алгоритмами и динамическим программированием можно пояснить так: на каждом шаге жадный алгоритм берет "самый жирный кусок", а потом уже пытается сделать наилучший выбор среди оставшихся, каковы бы они ни были; алгоритм динамического программирования принимает решение, просчитав заранее последствия для всех вариантов.
Рекурсия
Рекурсия – определение функции через саму себя. Логика рекурсивной функции как правило состоит из двух ветвей. Длинная ветвь вызывает эту же функцию с другими параметрами, чтобы накопить результат. Короткая ветвь определяет критерий выхода из рекурсии.
Рекурсия упрощает код и делает его декларативным. Рекурсия поощряет мыслить функционально и избегать побочных эффектов.
Неоптимизированная рекурсия приводит к накладным расходам ресурсов. При большом количестве итераций можно превысить лимит на число рекурсивных вызовов (recursion depth limit reached).
Какие ограничения специфичны для рекурсии в Python?
По умолчанию sys.getrecursionlimit() вернёт ограничение в 1000.
Хвостовая рекурсия
Особый вид рекурсии, когда функция заканчивается вызовом самой себя без дополнительных операторов. Когда это условие выполняется, компилятор разворачивает рекурсию в цикл с одним стек-фреймом, просто меняя локальные переменные от итерации к итерации.
Так, классическое определение рекурсивного факториала return N * fact(N - 1) не поддерживает хвостовую рекурсию, потому что для каждого стек-фрейма придется хранить текущее значение N.
Чтобы сделать рекурсии хвостовой, добавляют параметры-аккумуляторы. Благодаря им функция знает о своем текущем состоянии. Пусть параметр acc по умолчанию равен 1. Тогда запись с хвостовой рекурсией будет выглядеть так:
def fact(N, acc=1):
if N == 1:
return acc
else:
return fact(N - 1, acc * N)
Самые распространенные методы решения задач Leetcode
- Метод скользящего окна (Sliding Window)
- Метод двух указателей (Two Pointers)
- Нахождение цикла (Fast & Slow Pointers)
- Интервальное слияние (Merge Intervals)
- Цикличная сортировка (Cyclic Sort)
- In-place Reversal для LinkedList (In-place Reversal of a LinkedList)
- Поиск в ширину (Tree Breadth-First Search)
- Поиск в глубину (Tree Depth First Search)
- Две кучи (Two Heaps)
- Подмножества (Subsets)
- Модифицированный бинарный поиск (Modified Binary Search)
- Побитовое исключающее ИЛИ (Bitwise XOR)
- Наибольшие K элементов (Top K Elements)
- K-образное слияние (K-way Merge)
- Задача о рюкзаке 0-1 (0/1 Knapsack)
- Задача о неограниченном рюкзаке (Unbounded Knapsack)
- Числа Фибоначчи (Fibonacci Numbers)
- Наибольшая последовательность-палиндром (Palindromic Subsequence)
- Наибольшая общая подстрока (Longest Common Substring)
- Топологическая сортировка (Topological Sort)
- Чтение префиксного дерева (Trie Traversal)
- Количество островов в матрице (Number of Island)
- Метод проб и ошибок (Trial & Error)
- Система непересекающихся множеств (Union Find)
- Задача: найти уникальные маршруты (Unique Paths)
9. Базы данных (с уклоном в PostgreSQL)
БД и СУБД
База данных (БД) — набор взаимосвязанных данных, хранение, доступ и обработка которых осуществляется посредством систем управления базами данных (СУБД).
СУБД отвечает за:
• поддержку языка запросов БД;
• хранение данных;
• организацию и оптимизацию процессов записи и извлечения данных.
Отталкиваясь от способа доступа к БД, СУБД можно разделить на три класса:
• файл-серверные. Файлы хранятся централизованно на сервере, СУБД установлены на клиентских машинах. Примеры — Microsoft Access, Visual FoxPro;
• клиент-серверные. И файлы данных, и СУБД располагаются централизованно на сервере. Примеры — MySQL, PostgreSQL, Oracle Database, MS SQL Server, MariaDB(форк MySQL);
• встраиваемые. Встраиваемая СУБД предназначена, как правило, для работы с данными одного конкретного приложения и не рассчитана на работу с внешними запросами. Реализуется, как правило, не в виде самостоятельно инсталлируемого ПО, а в виде подключаемой библиотеки. Самый известный практический пример — SQLite, остальные встраиваемые СУБД гораздо менее распространены.
Обобщенные требования к РСУБД изложены математиком Эдгаром Коддом в «12 правилах Кодда».
Далее мы сосредоточимся на СУБД PostgreSQL. Также, если не указано иное, при рассмотрении примеров SQL-запросов будет использоваться синтаксис PostgreSQL.
Транзакция
Остановимся чуть более подробно на механизме транзакций, на требованиях, предъявляемых к транзакциям и на методах соблюдения этих требований.
Формально, транзакция — неделимая последовательность действий (атомарная операция, группа операций, которые выполняются как единое целое), обеспечивает выполнение либо всех действий из последовательности, либо ни одного. Если в ходе выполнения транзакции произошел сбой, состояние системы должно быть возвращено к исходному, уже выполненные действия должны быть отменены.
Если же говорить неформально, поток транзакций — это динамическое изменение состояний базы данных, схожее с сердцебиением или дыханием у человека. Также как для человека, правильное, согласованное сердцебиение является залогом нормального функционирования; разлад потока транзакций может привести с существенным сбоям и падению базы данных.
Канонический пример — списывание денег с одного счета и зачисление на другой, для чего необходимы два процесса проведения изменений, которые гарантированно должны выполниться или не выполниться вместе. Зачисление денег на один счет без списывания со второго, или наоборот, списывание денег с одного счета без изменений на втором — совершенно недопустимая операция, ставящая под сомнение самое существование жизни, вселенной и всего такого.
В частном случае в группе транзакций может быть одна операция.
В Postgre транзакция начинается с команды BEGIN и заканчивается командой COMMIT либо отменяется командой ROLLBACK.
Транзакция — способ группировки приложением нескольких операций записи и чтения в одну логическую единицу. По сути, все операции записи и чтения в ней выполняются как одна: вся транзакция или целиком выполняется успешно (с фиксацией изменений), или целиком завершается неудачно (с прерыванием и откатом). Во втором случае приложение может спокойно попробовать выполнить её еще раз. Транзакции значительно упрощают для приложения обработку ошибок, поскольку нет нужды заботиться о частичных отказах, то есть случаях, когда часть операций завершилась успешно, а часть — нет (неважно, по каким причинам).
По сути, транзакции - универсальный механизм реагирования на разнородные проблемы:
программное или аппаратное обеспечение базы данных может отказать в любой момент (в том числе посередине операции записи);
в любой момент может произойти фатальный сбой приложения (в том числекогда последовательность операций выполнена наполовину);
разрывы сети могут неожиданно отрезать приложение от базы данных или один узел базы от другого;
несколько клиентов могут производить операции записи одновременно, перезаписывая изменения друг друга;
клиент может прочитать данные, которые не имеют смысла, поскольку были обновлены только частично;
состояния гонки между клиентами могут привести к неожиданным ошибкам.
Транзакции - не закон природы, они были созданы с определенной целью, а именно для упрощения модели программирования приложений, работающих с базами данных. Благодаря использованию транзакций приложение получает возможность игнорировать определенные сценарии ошибок и вопросы конкурентного доступа, поскольку вместо него этим занимается база (данный аспект называется гарантиями функциональной безопасности).
В конце 2000-х годов приобрели популярность нереляционные (NoSQL) базы данных. Их целью было улучшить существующее положение дел с реляционными БД с помощью новых моделей данных, репликации и секционирования. Транзакции оказались главной жертвой этого новшества: многие базы нового поколения полностью от них отказались или поменяли значение термина: теперь он стал у них означать намного более слабый набор функциональных гарантий, чем ранее.
Вместе с шумихой вокруг этого нового обильного урожая распределенных баз данных стало широко распространяться мнение, что транзакции — антоним масштабируемости и любой крупномасштабной системе необходимо отказаться от них ради сохранения хорошей производительности и высокой доступности. С другой стороны, производители БД иногда представляют транзакционные функциональные гарантии как обязательное требование для «серьезных приложений», оперирующих «ценными данными». Обе точки зрения — чистейшей воды преувеличение.
Истина не столь проста: как и любое другое техническое проектное решение, транзакции имеют свои достоинства и ограничения. Чтобы лучше разобраться в их плюсах и минусах, глубже заглянем в подробности предоставляемых транзакциями функциональных гарантий — как при обычной эксплуатации, так и в различных нестандартных (хотя и реалистичных) обстоятельствах.
ACID
Обеспечиваемые транзакциями гарантии функциональной безопасности часто описываются известной аббревиатурой ACID (atomicity, consistency, isolation, durability).
Аббревиатура был придумана в 1983 году Тео Хэрдером и Андреасом Ройтером в попытке создать четкую терминологию для механизмов обеспечения отказоустойчивости в базах данных. Однако на практике реализации ACID в разных базах отличаются друг от друга. Например, существуют серьезные различия в понимании термина «изоляция», так что на сегодняшний день заявление о «совместимости системы с ACID» не дает четкого представления о предоставляемых гарантиях. К сожалению, ACID стал скорее термином из области маркетинга.
-
Атомарность (atomicity). Это свойство означает, что либо транзакция будет зафиксирована в базе данных полностью, т. е. будут зафиксированы результаты выполнения всех ее операций, либо не будет зафиксирована ни одна операция транзакции.
-
Согласованность (consistency). Это свойство предписывает, чтобы в результате успешного выполнения транзакции база данных была переведена из одного консистентного состояния в другое консистентное состояние.
Идея согласованности в смысле ACID состоит в том, что определенные утверждения относительно данных (инварианты) должны всегда оставаться справедливыми; например, в системе бухгалтерского учета кредит всегда должен сходиться с дебетом по всем счетам. Если транзакция начинается при допустимом (в соответствии с этими инвариантами) состоянии базы данных и любые производимые во время транзакции операции записи сохраняют это свойство, то можно быть уверенными, что система всегда будет удовлетворять инвариантам.
- Изолированность (isolation). Во время выполнения транзакции другие транзакции должны оказывать по возможности минимальное влияние на нее.
Изоляция в смысле ACID означает, что конкурентно выполняемые транзакции изолированы друг от друга — они не могут помешать друг другу, и каждая транзакция выполняется так, будто она единственная во всей базе. БД гарантирует, что результат фиксации транзакций такой же, как если бы они выполнялись последовательно, хотя в реальности они могут выполняться конкурентно.
- Долговечность, Сохраняемость или Устойчивость (durability, перевод разнится даже в авторитетных источниках, например, в "Википедии" и в русском переводе книги Мартина Клеппмана "Высоконагруженные приложения"). После успешной фиксации транзакции пользователь должен быть уверен, что данные надежно сохранены в базе данных и впоследствии могут быть извлечены из нее, независимо от последующих возможных сбоев в работе системы.
Именно через призму соблюдения требований ACID (или отказа от их части) нужно рассматривать вопросы надежности, предсказуемости и масштабируемости БД.
На практике реализации ACID в разных базах отличаются друг от друга. Например, существуют серьезные различия в понимании термина «изоляция». Идея в целом правильная, но дьявол, как обычно, кроется в деталях. На сегодняшний день заявление о «совместимости системы с ACID» не дает четкого представления о предоставляемых гарантиях. К сожалению, ACID стал скорее термином из области маркетинга.
Атомарность, изоляция и сохраняемость — свойства базы данных, в то время как согласованность (в смысле ACID) — свойство приложения. Оно может полагаться на свойства атомарности и изоляции базы данных, чтобы обеспечить согласованность, но не на одну только базу. Следовательно, букве C в каком-то смысле на самом деле не место в аббревиатуре ACID. Буква C была добавлена в ACID в статье Хэрдера и Ройтера "Principles of Transaction-Oriented Database Recovery" для красоты аббревиатуры и не считалась в то время (1983 год) чем-то важным.
Проблемы параллельного доступа с использованием транзакций
Транзакции можно исполнять последовательно или параллельно. И если в первом случае все более-менее понятно и предсказуемо, то в случае параллельного исполнения транзакций возможны следующие проблемы:
Фантомное чтение (phantom reads) — одна транзакция в ходе своего выполнения несколько раз выбирает множество строк по одним и тем же критериям. Другая транзакция в интервалах между этими выборками добавляет строки или изменяет столбцы некоторых строк, используемых в критериях выборки первой транзакции, и успешно заканчивается. В результате получится, что одни и те же выборки в первой транзакции дают разные множества строк.
Неповторяющееся чтение (non-repeatable read) — при повторном чтении в рамках одной транзакции ранее прочитанные данные оказываются изменёнными.
«Грязное» чтение (dirty read) — чтение данных, добавленных или изменённых транзакцией, которая впоследствии не подтвердится (откатится);
Потерянное обновление (lost update) — при одновременном изменении одного блока данных разными транзакциями теряются все изменения, кроме последнего.
Уровни изоляции транзакций
Для борьбы с проблемами, порождаемыми параллельным исполнением транзакций у нас есть соответствующий инструмент — уровни изоляции транзакций — фактически, выбор между скоростью работы и обеспечением согласованности данных, т. к. при выполнении параллельных транзакций в СУБД всегда допускается получение несогласованных данных, и разработчик должен найти баланс между количеством параллельных транзакций и согласованностью данных.
Стандарт SQL-92 определяет шкалу из четырёх уровней изоляции: чтение незафиксированных данных, чтение фиксированных данных, повторяющееся чтение, упорядочивание. Первый из них является самым слабым, последний — самым сильным, каждый последующий включает в себя все предыдущие.
Чтение незафиксированных данных (read uncommitted)
Низший (первый) уровень изоляции. Если несколько параллельных транзакций пытаются изменять одну и ту же строку таблицы, то в окончательном варианте строка будет иметь значение, определенное всем набором успешно выполненных транзакций. При этом возможно считывание не только логически несогласованных данных, но и данных, изменения которых ещё не зафиксированы, т. к. транзакции, выполняющие только чтение, при данном уровне изоляции никогда не блокируются. Данные блокируются на время выполнения команды записи, что гарантирует, что команды изменения одних и тех же строк, запущенные параллельно, фактически выполнятся последовательно, и ни одно из изменений не потеряется.
Чтение фиксированных данных (read committed)
Большинство СУБД, в частности, Microsoft SQL Server, PostgreSQL и Oracle, по умолчанию используют именно этот уровень. На этом уровне обеспечивается защита от чтения промежуточных данных, тем не менее, в процессе работы одной транзакции другая может быть успешно завершена и сделанные ею изменения зафиксированы. В итоге первая транзакция будет работать с другим набором данных.
Метод read committed реализуется либо при помощи блокировки данных на чтение во время записи (теряем время), либо на хранении копии данных, снятой до начала записи (теряем ОЗУ).
Повторяющееся чтение (repeatable read)
Уровень, при котором читающая транзакция блокирует изменения данных, прочитанных ею ранее. При этом никакая другая транзакция не может изменять данные, читаемые текущей транзакцией, пока та не окончена.
Упорядочивание (serializable)
Самый высокий уровень изолированности; транзакции полностью изолируются друг от друга, каждая выполняется так, как будто параллельных транзакций не существует. Только на этом уровне параллельные транзакции не подвержены эффекту «фантомного чтения».
| Уровень изоляции | Фантомное чтение | Неповторяющееся чтение | «Грязное» чтение | Потерянное обновление |
|---|---|---|---|---|
| Отсутствие изоляции | + | + | + | + |
| Read uncommitted | + | + | + | - |
| Read committed | + | + | - | - |
| Repeatable read | + | - | - | - |
| Serializable | - | - | - | - |
Задача любой кластерной СУБД — разделение данных между узлами таким образом, чтобы для пользователя они представляли единое целое, при этом система могла использовать мощности всех узлов одновременно.
Репликация БД
Репликация баз данных может использоваться во многих СУБД, обычно в режиме “ведущий–ведомый”, где роль ведущего сервера играет оригинал (master), а его копии являются ведомыми (slave).
Ведущая база данных обычно поддерживает только операции записи. Ведомые БД получают от ведущей копии ее содержимого и поддерживают только операции чтения. Все команды для модификации данных, такие как вставка, удаление или обновление, должны направляться ведущей базе данных. В большинстве приложений чтение происходит намного чаще, чем запись, поэтому ведомых БД обычно больше, чем ведущих.
Преимущества репликации базы данных:
Повышенная производительность. В модели «ведущий–ведомый» все операции записи и обновления происходят на ведущих узлах, а операции чтения распределяются между ведомыми. Это улучшает производительность, увеличивая количество запросов, которые можно обрабатывать параллельно.
Надежность. Если один из ваших серверов с базой данных сломается из-за стихийного бедствия, такого как тайфун или землетрясение, данные не будут утеряны. Вам не нужно беспокоиться о потере данных, так как они реплицируются по разным местам.
Высокая доступность. За счет репликации данных по разным местам ваш веб-сайт будет продолжать работать, даже если одна из БД выйдет из строя, поскольку у вас по-прежнему будет доступ к данным, размещенным на другом сервере.
Три популярных алгоритма репликации изменений между узлами: репликация с одним ведущим узлом (single-leader), с несколькими ведущими узлами (multi-leader) и без ведущего узла (leaderless).
Репликация с одним ведущим узлом
Схема ее работы следующая.
-
Одна из реплик назначается ведущим (master) узлом. Клиенты, желающие записать данные в базу, должны отправить свои запросы ведущему узлу, который сначала записывает новые данные в свое локальное хранилище.
-
Другие реплики называются ведомыми (followers) узлами. Всякий раз, когда ведущий узел записывает в свое хранилище новые данные, он также отправляет информацию об изменениях данных всем ведомым узлам в качестве части журнала репликации (replication log) или потока изменений (change stream). Все ведомые узлы получают журнал от ведущего и обновляют соответствующим образом свою локальную копию БД, применяя все операции записи в порядке их обработки ведущим узлом.
-
Когда клиенту требуется прочитать данные из базы, он может выполнить запрос или к ведущему узлу, или к любому из ведомых. Однако запросы на запись разрешено отправлять только ведущему (ведомые с точки зрения клиента предназначены только для чтения).
Такой режим репликации — встроенная возможность многих реляционных баз данных, например PostgreSQL (начиная с версии 9.0) или MySQL. MongoDB и Kafka тоже имеют такие возможности.
Создание новых ведомых узлов.
Время от времени приходится создавать новые ведомые узлы — с целью увеличить количество реплик или заменить сбойные узлы. Как же гарантировать, что новый ведомый узел будет содержать точную копию данных ведущего?
Процесс выглядит следующим образом.
-
Сделать согласованный снимок состояния БД ведущего узла на определенный момент времени — по возможности без блокировки всей базы. В большинстве баз такая возможность есть, так как она нужна и для резервного копирования. В некоторых случаях понадобятся сторонние утилиты, например innobackupex для СУБД MySQL.
-
Скопировать снимок состояния на новый ведомый узел.
-
Ведомый узел подключается к ведущему и запрашивает все изменения данных, произошедшие с момента создания снимка. Для этого нужно, чтобы снимок состояния соотносился с определенной позицией в журнале репликации ведущего узла. Сама позиция называется по-разному: в PostgreSQL — регистрационным номером транзакции в журнале (log sequence number), в MySQL — координатами в бинарном журнале (binlog coordinates).
-
Когда ведомый узел завершил обработку изменений данных, произошедших с момента снимка состояния, говорят, что он наверстал упущенное. После этого он может продолжать обрабатывать поступающие от ведущего узла изменения данных.
На практике создание и настройка ведомого узла очень зависит от используемой базы данных. В ряде систем этот процесс полностью автоматизирован, а в других — представляет собой запутанную многошаговую последовательность действий, которую должен вручную выполнить администратор.
Отказ ведомого узла: наверстывающее восстановление
Каждый ведомый узел хранит на своем жестком диске журнал полученных от ведущего изменений данных. В случае сбоя и перезагрузки ведомого узла или временного прекращения работы участка сети между ведущим и ведомым узлами последний может легко возобновить работу: из своего журнала он знает, какая транзакция была обработана перед сбоем. Следовательно, ведомый узел способен подключиться к ведущему и запросить все изменения данных, имевшие место за то время, пока он был недоступен.
Отказ ведущего узла: восстановление после отказа
Справиться с отказом ведущего узла сложнее: необходимо «повысить в звании» один из ведомых до ведущего, настроить клиенты на отправку записей новому ведущему, а другие ведомые должны начать получать изменения данных от нового ведущего. Этот процесс называется восстановлением после отказа (failover).
Восстановление после отказа может выполняться вручную (администратор получает оповещение об отказе ведущего узла и принимает соответствующие меры по созданию нового ведущего) или автоматически. Автоматически процесс восстановления после отказа обычно состоит из следующих шагов.
-
Установить отказ ведущего узла. Многое может потенциально пойти не так: фатальные сбои, перебои питания, сетевые проблемы и т. д. Не существует надежного способа определить, что именно пошло не так. Поэтому во многих системах для данной цели используется превышение времени ожидания: узлы постоянно обмениваются сообщениями друг с другом, и если один из них не отвечает в течение определенного времени (скажем, 30 секунд), то считается, что он не работает (это не относится к случаю преднамеренного отключения ведущего узла для запланированного техобслуживания).
-
Выбрать новый ведущий узел. Здесь можно задействовать процесс «выборов» (ведущий узел выбирается в соответствии с большинством оставшихся реплик), или же этот ведущий назначает предварительно выбранный узел-контроллер (controller node). Оптимальным кандидатом на роль нового ведущего узла обычно является реплика с наиболее свежими изменениями данных, полученными от старого (в целях минимизации потерь данных). Согласование нового ведущего между всеми узлами — задача консенсуса, которую мы обсудим позже.
-
Настроить систему на использование нового ведущего узла. Клиенты должны начать отправлять запросы на запись новому ведущему узлу (мы обсудим это позже). Если старый ведущий узел возобновляет работу, может оказаться, что он продолжает считать себя ведущим и не осознает решение остальных реплик считать его недееспособным. Система должна обеспечить превращение старого ведущего узла в ведомый и признание им нового ведущего.
Репликация с несколькими ведущими узлами
Сравним схему с одним и несколькими ведущими узлами в смысле развертывания в нескольких ЦОДах.
Производительность. При схеме с одним ведущим узлом каждая операция записи должна проходить через Интернет в ЦОД, содержащий ведущий узел. Это может существенно задержать операцию записи и вообще пойдет вразрез с идеей нескольких ЦОДов. При схеме с несколькими ведущими узлами все операции записи будут обрабатываться в локальных ЦОДах и реплицироваться асинхронно в остальные ЦОДы. Таким образом, сетевые задержки между ЦОДами становятся незаметными для пользователей, а значит, субъективная производительность возрастет.
Устойчивость к перебоям в обслуживании ЦОДов. При схеме с одним ведущим узлом в случае отказа ЦОДа, в котором находится ведущий узел, восстановление после сбоя сделает ведомый узел в другом ЦОДе ведущим. При схеме с несколькими ведущими узлами каждый ЦОД может работать независимо от остальных и репликация наверстывает упущенное после возобновления работы отказавшего ЦОДа.
Устойчивость к проблемам с сетью. Трафик между ЦОДами обычно проходит через общедоступный Интернет — менее надежный, чем локальная сеть внутри ЦОДа. Схема с одним ведущим узлом очень чувствительна к сбоям работы этого соединения между ЦОДами, поскольку операции записи выполняются через него синхронно. Схема асинхронной репликации с несколькими ведущими узлами обычно более устойчива к проблемам с сетью: временные сбои работы сети не мешают обработке операций записи.
Некоторые базы данных поддерживают схему с несколькими ведущими узлами по умолчанию, но она также часто реализуется с помощью внешних утилит, например BDR для PostgreSQL.
Поскольку репликация с несколькими ведущими узлами — дополнительно доработанная возможность во многих базах данных, есть немало коварных подводных камней настроек и неожиданных взаимодействий с другими возможностями этих баз. Например, проблематичным может оказаться использование автоматически увеличиваемых ключей, триггеров и ограничений целостности. Поэтому репликация с несколькими ведущими узлами часто считается опасной вещью, которой лучше избегать по мере возможности.
Офлайн-клиенты
Другая ситуация, подходящая для использования репликации с несколькими ведущими узлами, — приложения, которые должны продолжать работать, даже будучи отключенными от Интернета.
Например, рассмотрим приложения-календари на мобильном телефоне, ноутбуке и других устройствах. У вас должна сохраняться возможность в любой момент просмотреть расписание ваших совещаний (выполнять запросы на чтение) и вводить в него новые совещания (выполнять запросы на запись) независимо от наличия соединения с Интернетом. Любые выполняемые в офлайн-режиме изменения должны синхронизироваться с сервером и остальными вашими устройствами при следующем подключении к Интернету.
В этом случае у каждого устройства есть своя локальная база данных, служащая ведущим узлом (она принимает запросы на запись). Кроме того, происходит асинхронный процесс репликации (синхронизации) между репликами календаря на всех устройствах. Задержка репликации может составлять часы или даже дни в зависимости от того, когда появится доступ в Интернет.
С точки зрения архитектуры такая схема, по сути, то же самое, что и репликация с несколькими ведущими узлами между ЦОДами, доведенная до крайности: каждое устройство представляет собой ЦОД, а сетевое соединение между ними исключительно ненадежно.
Совместное редактирование
Приложения для совместного редактирования в режиме реального времени (realtime collaborative editing) предоставляют возможность нескольким людям редактировать документ одновременно. Например, Google Docs позволяет одновременно редактировать документ или электронную таблицу.
Обычно совместное редактирование не рассматривают как задачу репликации базы данных, но с ранее упомянутым офлайн-редактированием у него много общего. Когда пользователь редактирует документ, изменения немедленно применяются к его локальной реплике (состоянию документа в его браузере или клиентском приложении) и асинхронно реплицируются на сервер и на всех остальных пользователей, редактирующих этот же документ.
При необходимости обеспечить отсутствие конфликтов редактирования приложение должно, прежде чем пользователь сможет отредактировать документ, запросить на этот документ блокировку. Если другой пользователь хочет отредактировать тот же документ, он должен сначала подождать, пока первый не зафиксирует свои изменения и не снимет блокировку. Такая модель совместной работы эквивалентна репликации с одним ведущим узлом и выполнением транзакций на ведущем узле.
Однако для ускорения совместной работы желательно сделать блок изменений очень маленьким (например, одно нажатие клавиши) и по возможности избегать блокировок. Такой подход позволяет редактировать нескольким пользователям одновременно, но также влечет за собой все проблемы репликации с несколькими ведущими узлами, включая необходимость разрешения конфликтов.
Сходимость к согласованному состоянию
База данных с одним ведущим узлом применяет операции записи в последовательном порядке: при наличии нескольких изменений одного поля последняя по времени операция записи определяет итоговое значение этого поля.
В схеме с несколькими ведущими узлами нельзя задать порядок операций записи, так что непонятно, каким должно быть итоговое значение. Если в одном ведущем узле 1 название A сначала меняется на B, а позднее на C, а ведущем узле 2 оно сначала меняется на C, а потом — на B, то никакой из этих порядков нельзя назвать «более правильным, чем другой».
Если все реплики просто будут применять операции записи в том порядке, в котором они эти операции записи получают, то база данных в конце концов окажется в несогласованном состоянии: итоговое значение будет C в ведущем узле 1 и B — в ведущем узле 2. Это недопустимо: каждая схема репликации обязана обеспечить наличие одинаковых данных во всех репликах. Следовательно, база должна разрешать конфликт конвергентным способом, то есть все реплики должны сойтись к одному значению после репликации всех изменений.
Существует множество способов конвергентного разрешения конфликтов.
Присвоить каждой операции записи уникальный идентификатор (например, метку даты/времени, случайное длинное число, UUID или хеш ключа и значения), после чего просто выбрать операцию («победителя») с максимальным значением этого идентификатора, а остальные отбросить. В случае использования метки даты/времени в качестве идентификатора этот метод известен под названием «выигрывает последний» (last write wins, LWW). Хотя этот подход очень популярен, ему свойственно терять данные [35].
Присвоить уникальный идентификатор каждой реплике и считать, что у исходящих от реплик с большим номером операций записи есть приоритет перед теми, которые исходят от реплик с меньшим. Этот подход также приводит к потерям данных.
Заносить конфликты в заданную в явном виде структуру данных для хранения и написать код приложения, который бы разрешал конфликты позднее (возможно, спрашивая для этого пользователя).
Секционирование БД
Секционирование (или шардинг) - в отличие от репликации, при секционировании происходит разбиение большой базы данных на небольшие подмножества, называемые секциями (partitions), в результате чего разным узлам можно поставить в соответствие различные секции.
Репликация и секционирование не противоречат другу, их можно применять одновременно. Более того, секционирование обычно идет бок о бок с репликацией, вследствие чего копии каждой из секций хранятся на нескольких узлах. Это значит, что, хотя каждая конкретная запись относится только к одной секции, храниться она может в нескольких различных узлах в целях отказоустойчивости.
Обычно секции задаются таким образом, что каждый элемент данных (запись, строка или документ) относятся ровно к одной секции. Этого можно достичь множеством способов, часть из которых мы обсудим подробно ниже. Фактически каждая секция сама по себе является маленькой БД, хотя база способна поддерживать операции, затрагивающие сразу несколько секций.
Основная цель секционирования данных — масштабируемость. Разные секции можно разместить в различных узлах в кластере, не предусматривающем разделения ресурсов. Следовательно, большой набор данных можно распределить по многим жестким дискам, а запросы — по многим процессорам.
Секционирование по диапазонам значений ключа
Один из методов секционирования — назначить каждой из секций непрерывный диапазон значений ключа (от какого-то минимального значения до какого-то максимального), подобно томам бумажной энциклопедии. Если вам известны границы между диапазонами, то можно легко определить, в какой секции содержится нужный ключ. Если вдобавок знать, с какими секциями какие узлы соотносятся, то можно выполнить запрос непосредственно к соответствующему узлу (или — в случае энциклопедии — снять требуемую книгу с полки).
Однако недостатком секционирования по диапазонам значений ключа является то, что некоторые паттерны доступа приводят к возникновению горячих точек (hot spot, секции с непропорционально высокой нагрузкой). Если ключ представляет собой метку даты/времени, то секции соответствуют отрезкам времени, например одна секция на день. К сожалению, в случаях, когда данные записываются в базу по мере получения значений от датчиков, все операции записи будут выполняться в одной секции (сегодняшней), вследствие чего эта секция окажется перегружена операциями записи, а остальные будут простаивать.
Чтобы избежать этой проблемы в базе данных, полученных с датчиков, необходимо использовать в качестве первого элемента ключа не метку даты/времени, а нечто иное. Например, можно предварить метку даты/времени названием датчика, и, как следствие, секционирование будет выполняться сначала по названию датчика, а только потом по времени. При условии одновременной работы множества датчиков нагрузка по записи распределится по секциям более равномерно. Но при необходимости получить значения с нескольких датчиков в пределах определенного промежутка времени придется выполнять отдельный запрос по диапазону для каждого из названий датчиков.
Секционирование по хешу ключа
Многие распределенные базы данных, опасаясь асимметрии и горячих точек, используют для распределения ключей по секциям хеш-функцию. Хорошая хеш-функция получает на входе асимметричные данные и возвращает равномерно распределенные значения. Допустим, у вас есть 32-битная хешфункция, получающая на входе строковое значение. Для каждой строки на входе она возвращает псевдослучайное число в диапазоне от 0 до 232 – 1. Даже если входные строки очень похожи, их хеши равномерно распределены по этому интервалу. Например, СУБД Cassandra и MongoDB используют MD5.
При наличии подходящей хеш-функции для ключей можно поставить каждой секции в соответствие диапазон хешей (вместо диапазона ключей), и каждый ключ, чье значение находится в диапазоне данной секции, будет сохранен в ней.
Однако, к сожалению, при использовании для секционирования хеша ключа мы теряем удобное свойство секционирования по диапазонам значений ключа: возможность эффективно выполнять запросы по диапазонам. Смежные некогда ключи оказываются разбросаны по всем секциям, и порядок их сортировки теряется. В MongoDB при включении режима шардинга на основе хеша любой запрос по диапазону приходится отправлять все секции.
Асимметричные нагрузки и разгрузка горячих точек
Как уже обсуждалось выше, хеширование ключа для определения секции может несколько исправить ситуацию с горячими точками, но не избавиться от них полностью: в предельном случае, когда все операции записи и чтения выполняются для одного ключа, все запросы все равно приходятся на одну секцию.
Подобная нагрузка, вероятно, необычна, но не беспрецедентна: например, на сайтах соцсетей действия знаменитостей, насчитывающих миллионы подписчиков, могут вызывать бурю активности. Такие события могут привести к огромным объемам операций записи для одного ключа (где ключом, вероятно, будет ID пользователя этой знаменитости или ID действия, которое комментируют подписчики). Хеширование ключа не поможет в такой ситуации, ведь хеш двух одинаковых ключей — одинаковый.
На сегодняшний день большинство информационных систем не умеют автоматически выравнивать подобную высоко асимметричную нагрузку, вследствие чего снижение асимметрии — обязанность приложения. Например, если известно, что конкретный ключ — очень горячий, то простейшим решением будет добавление в начало или конец этого ключа случайного числа. Простое двузначное десятичное число приведет к разбиению операций записи для ключа равномерно по 100 различным ключам, что позволит распределить их по разным секциям.
Однако при условии разбиения операций записи по различным ключам операциям чтения придется совершать дополнительные действия по чтению и объединению данных для всех этих ключей. Описанный метод требует также дополнительных вспомогательных операций: добавлять случайное число имеет смысл только для небольшого числа горячих ключей, для абсолютного большинства ключей с низкими объемами операций записи это окажется лишними накладными расходами. Следовательно, понадобится отслеживать, какие ключи были разбиты.
Возможно, в будущем информационные системы смогут автоматически обнаруживать и выравнивать асимметричную нагрузку, но пока что вам придется самим обдумывать плюсы и минусы подобного подхода для ваших приложений.
NoSQL
Перед тем, как углубится в, собственно, Postgres, пройдёмся немного по классу СУБД, объединенных понятием NoSQL.
Так же, как рост размера файлов данных предопределил переход от файл-серверных БД к клиент-серверным, так и рост требований к масштабируемости и доступности вызвал появление и развитие NoSQL БД.
Нереляционные хранилища данных NoSQL делятся на две основные разновидности:
- Документоориентированные БД предназначены для тех сценариев использования, при которых данные поступают в виде отдельных документов и связи между документами минимальны.
- Графовые БД, наоборот, предназначены для сценариев применения, в которых любые данные потенциально могут быть связаны между собой.
Все три модели (реляционная, документная и графовая) в настоящее время широко используются, и все отлично работают в своих предметных областях. Одну модель можно эмулировать на языке другой (например, графовые данные разместить в реляционной БД), но результат этого часто достаточно неудобен в применении. Именно поэтому для разных целей прибегают к разным системам, а не к единому универсальному решению.
Одна из общих черт документоориентированных и графовых БД — отсутствие обязательной схемы для хранимых данных, что облегчает адаптацию приложений к изменению требований. Однако чаще всего приложение предполагает наличие какой-либо структуры данных, вопрос только в том, является ли схема явной (навязываемой при записи) или неявной (контролируемой при чтении).
Идея столбцовых хранилищ проста: нужно хранить рядом значения не из одной строки, а из одного столбца, причём каждый столбец хранится в отдельном файле.
Размещение данных по столбцам требует, чтобы файлы всех столбцов содержали строки в одинаковом порядке. Следовательно, при необходимости собрать воедино целую строку можно взять из всех файлов отдельных столбцов 20-й элемент и собрать их вместе для формирования 20-й строки таблицы.
Столбцовое хранилище часто очень хорошо поддается сжатию.
OLTP-системы обычно нацелены на работу с пользователями, и это означает огромное потенциальное количество запросов. Чтобы справиться с такой нагрузкой, приложения обычно затрагивают в каждом запросе только небольшое число строк. Программы запрашивают записи с помощью определенного ключа, а подсистема хранения задействует индекс для поиска данных с соответствующим ключом. Узким местом здесь обычно становится время перехода к нужной позиции на диске. Склады данных и подобные им аналитические системы менее широко известны, поскольку они в основном применяются бизнес-аналитиками, а не конечными пользователями. Склады обрабатывают намного меньшее количество запросов, чем OLTP-системы, но все запросы обычно очень ресурсоемки и требуют просмотра миллионов строк за короткое время. Узким место здесь обычно становится пропускная способность диска (а не время перехода к нужной позиции на нем). Все большую популярность для этой разновидности задач приобретают столбцовые хранилища.
Не все склады данных являются столбцовыми хранилищами: используются и обычные построчные БД, а также несколько других архитектур. Однако столбцовые хранилища работают намного быстрее при произвольных аналитических запросах, так что их популярность быстро растет.
Подумать над переходом на NoSQL стоит, если:
ваше приложение нуждается в экстремально низкой латентности;
вы работаете с неструктурированными данными, не имеющих реляционных связей;
вам нужно всего лишь сериализовать и десериализовать свои данные (JSON, XML и т. д.);
вам нужно работать с очень большим объемом данных.
Две, пожалуй, основные черты всех NoSQL решений — отказ от согласованности данных и относительно лёгкая масштабируемость.
В качестве обоснования неизбежности отказа от согласованности данных используется т. н. теорема CAP, утверждающая, что в любой реализации распределённых вычислений возможно обеспечить не более двух из трёх следующих свойств:
• согласованность данных (consistency) — во всех вычислительных узлах в один момент времени данные не противоречат друг другу;
• доступность (availability) — любой запрос к распределённой системе завершается корректным откликом, однако без гарантии, что ответы всех узлов системы совпадают;
• устойчивость к разделению (partition tolerance) — расщепление распределённой системы на несколько изолированных секций не приводит к некорректности отклика от каждой из секций.
В качестве противопоставления требованиям ACID, рассмотренным выше, для NoSQL сформулированы требования BASE:
• базовая доступность (basic availability) — подход к проектированию, требующий, чтобы сбой в некоторой части узлов приводил к отказу в обслуживании только для части пользователей при сохранении доступности в большинстве случаев;
• гибкое состояние (soft state) — возможность жертвовать хранением состояния сессий (промежуточные результаты, информация о контексте), концентрируясь на фиксации обновлений только критичных операций;
• согласованность в конечном счёте (eventual consistency) — данные могут быть некоторое время рассогласованы, при обеспечении согласования в практически обозримое время.
NoSQL решения были широко распространены примерно до 1970-х, когда на сцене появились реляционные БД. Повторный расцвет популярности NoSQL пришелся на 2010-е, когда Google на примере BigTable показал их эффективность применительно к некоторым типам задач.
Порой выбор между SQL и NoSQL нелегок и требует, конечно, большого опыта работы с БД, у обоих принципов есть свои преимущества и недостатки. В целом, кандидатами на применение NoSQL решений являются системы, объединяющие большое количество данных и некритичность к возможности доступа каждого пользователя к самой последней версии данных. Поисковая система (Google), крупный интернет-магазин (Amazon) — хорошие кандидаты на применение NoSQL. Напротив, банковская или биржевая система, в рамках которой каждому пользователю должны предоставляться самые свежие, актуальные данные требуют применения SQL решений.
Реляционная модель данных
PostgreSQL является реляционной СУБД (РСУБД). Теоретической основой для работы с реляционными базами данных служит раздел математики под названием реляционная алгебра. Реляционная модель данных (РМД) основана на математическом понятии «отношения» (relation), которое в практическом смысле, на уровне программиста БД, можно толковать как «таблица». Соответственно, реляционную модель данных можно упрощенно воспринимать как «табличную модель данных», т. е. построенную на основе двумерных таблиц, состоящих из строк и столбцов.
Вот небольшой словарик для «перевода» понятий реляционной алгебры на более простой практический «табличный» язык, который может вам пригодиться при чтении обучающей литературы:
сущность — объекты, содержащиеся в БД (например, сотрудники, клиенты, заказы);
отношение — таблица;
атрибут — столбец;
кортеж — строка (или запись);
результирующий набор — результат SQL-запроса (как правило, это таблица; в частном случае — таблица из одной строки и одного столбца, т. е., например, число или строка).
Работая с реляционной БД, программисту не нужно заботиться о низкоуровневом доступе к данным, достаточно описать, что нужно получить, а как именно — описывать не нужно, эту работу берет на себя БД.
Отсутствие низкоуровневого доступа — сильное ограничение, порождающее удобство использования БД (о плюсах ограничений мы поговорим чуть позже, в разделе «Архитектура»). Но это ограничение буквально «ломает мозг» программисту, не имевшему до этого дела с декларативными языками программирования.
Когда программист, работающий в императивной парадигме, пытается решить SQL-задачу, руки сами тянутся применить привычный набор инструментов. «Так, проходимся по таблице в цикле, отсеянные значения складываем во временную переменную, потом формируем ответ», но — SQL предлагает совсем другой, непривычный набор возможностей, не предполагая прямого управления ходом выполнения программы: «Скажи, что ты хочешь, а о деталях я позабочусь сам».
Если такой подход для вас внове, не отчаивайтесь; Джо Селко в своей книге «Thinking in Sets» сразу рекомендует готовиться к длительной перестройке процесса мышления: «Я ориентирую студентов на срок около года, полностью посвященного программированию на SQL, только тогда вас может посетить прозрение и вы начнете думать на языке SQL» («I have been telling students that you need about one year of full-time SQL programming before you have an epiphany and start thinking in SQL»).
Так что (продолжая цитировать книгу Селко) потихоньку избавляйтесь от своего «procedural programming mindset», который приводит к «overly complex and inefficient code», и «change the way you think about the problems you solve with SQL programs». Или, по простому, как советуют пользователи Stackoverflow в этом топике, «прекратите думать о построчной обработке, подумайте над решением задачи, основанном на работе со множествами».
Язык SQL
SQL (structured query language, язык структурированных запросов) — декларативный (описательный, непроцедурный) язык, стандарт для работы с данными во всех реляционных СУБД.
Операторы SQL традиционно делят на:
операторы определения данных (data definition language, DDL), например, CREATE, ALTER, DROP;
операторы манипулирования данными (data manipulation language, DML), таких как SELECT, INSERT, DELETE;
операторы управления транзакциями (transaction control language, TCL), например, COMMIT, ROLLBACK;
операторы управления привилегиями доступа (data control language, DCL), к примеру, GRANT, REVOKE, DENY.
Многообразие диалектов СУБД. ANSI SQL.
Прежде чем начать, наконец-то, писать запросы к БД, нам нужно определиться с конкретной СУБД, которую мы будем использовать в дальнейшем (спойлер — как и следует из названия главы, «Базы данных (с уклоном в PostgreSQL)», это будет Postgres). К сожалению, диалекты языка SQL для разных СУБД достаточно сильно различаются и писать универсальные запросы, подходящие ко всем популярным СУБД, у нас не получится, волей-неволей придётся сосредоточиться на одной конкретной реализации.
Общий знаменатель у разных диалектов есть, называется он ANSI SQL и имеет несколько актуальных редакций, от SQL-92 до SQL-2023. SQL-92, несмотря на дату публикации, до сих пор вполне актуален и изучение ANSI SQL стоит начать именно с него. К сожалению, ANSI SQL выступает скорее в качестве своеобразного маяка, определяющего разработку СУБД в целом, но не накладывающего жестких ограничений на синтаксис языка.
Существуют попытки создать обучающие руководства на базе ANSI SQL, например, Wikibook SQL, но при практическом изучении лучше сразу ориентироваться на конкретный диалект. ANSI SQL покрывает примерно 90 % возможностей каждого конкретного диалекта, что вроде бы выглядит как достаточно солидный базис, однако в практических сценариях желание использовать для обучения и работы ANSI SQL быстро исчезает, т. к. разница между диалектами слишком велика и попытка решения реальных задач на ANSI SQL приводит к постоянным поискам компромисса и неиспользованию возможностей вашего инструмента.
Учитывая сильное расхождение между диалектами и то, что часть возможностей ANSI SQL не реализована ни в одном из существующих диалектов (как сетуют разработчики Wikibook SQL, 18 мегабайт спецификации на 1400 страницах — не фунт изюму), для реальной работы придется выбрать какой-то один диалект SQL. Но это не такая большая проблема, как кажется, и после изучения одного из диалектов остальные варианты дадутся вам уже гораздо проще.
Выбор СУБД. SQLite, MySQL, PostgreSQL, SQL Server, Oracle SQL.
Как я уже говорил, в дальнейшем мы будем использовать PostgreSQL. Прежде чем окончательно перейти на Postgres, уделим немного внимания его конкурентам.
SQLite
Small. Fast. Reliable. Choose any three.
https://sqlite.org/index.html
https://github.com/sqlite/sqlite
https://habr.com/ru/post/149356/
https://github.com/sqlitebrowser/sqlitebrowser
Syntax Diagrams https://www.sqlite.org/syntaxdiagrams.html
Server-less database engine that stores each database into a separate file.
MySQL
SQL Server
Oracle SQL
MongoDB
MongoDB — документно-ориентированная NoSQL-система управления базами данных, использующая гибкую модель хранения данных в формате BSON (бинарный JSON), что позволяет эффективно работать с частично структурированными и динамически изменяющимися данными. Данные организуются в коллекции, которые содержат документы — самостоятельные записи с полями и значениями различных типов (строки, числа, массивы, вложенные объекты), при этом схема документов может варьироваться в пределах одной коллекции, обеспечивая гибкость разработки. MongoDB поддерживает индексирование, включая составные, текстовые, геопространственные и частичные индексы, что ускоряет выполнение запросов и агрегаций.
Для горизонтального масштабирования используется реплицирование: данные распределяются по кластерам на основе ключей реплицирования, автоматически балансируя нагрузку и обеспечивая отказоустойчивость через наборы реплик (replica sets), где данные копируются между узлами, а выбор первичного узла происходит автоматически при сбоях. Запросы выполняются с использованием богатого API, включая операции CRUD, агрегационные пайплайны (с операторами вроде $match, $group, $lookup для JOIN-подобных операций) и MapReduce для сложной аналитики.
Начиная с версии 4.0, MongoDB поддерживает мультидокументные ACID-транзакции, гарантируя атомарность и изоляцию операций. Движок хранения WiredTiger обеспечивает устойчивость данных через журнал транзакций (write-ahead log), управление конкурентным доступом на уровне документа и сжатие данных для экономии дискового пространства. Безопасность реализована через аутентификацию (SCRAM, LDAP, Kerberos), ролевое управление доступом (RBAC), шифрование данных на уровне поля и транспорта (TLS/SSL), а также аудит операций. Интеграция с экосистемой осуществляется через драйверы для языков (Python, JavaScript, Java и др.), инструменты мониторинга (MongoDB Atlas, Ops Manager) и коннекторы для Apache Kafka. Оптимизирована для сценариев с высокой нагрузкой на чтение/запись, работы с большими объемами данных и реального времени, сохраняя низкую задержку благодаря и эффективному использованию ресурсов.
Apache Cassandra
Apache Cassandra — это распределённая колоночная NoSQL-СУБД, ориентированная на высокую доступность, горизонтальную масштабируемость и обработку больших объемов данных с низкой задержкой, особенно в сценариях с интенсивной записью. Данные хранятся в виде разреженных распределенных таблиц, организованных в keyspaces (пространства ключей), где каждая таблица состоит из строк с динамическими столбцами, группируемыми в column families.
Модель данных основана на логике wide-column store: каждая строка идентифицируется партиционным ключом (распределение данных по узлам кластера) и может включать кластеризующие столбцы для сортировки внутри партиции. Cassandra использует распределенную архитектуру «ring» с децентрализованным управлением: данные автоматически распределяются между узлами через consistent hashing (алгоритм распределения на основе токенов), а репликация (настраиваемый replication factor) обеспечивает отказоустойчивость через стратегии вроде SimpleStrategy или NetworkTopologyStrategy для географически распределённых кластеров.
Запросы выполняются на языке CQL (Cassandra Query Language), синтаксически близком к SQL, но с ограничениями (например, отсутствие JOIN, ограниченная поддержка агрегаций), акцентирующим доступ на шаблонах доступа через первичный ключ. Для ускорения запросов могут применяться вторичные индексы, материализованные представления и SASI-индексы, но основная оптимизация достигается через проектирование таблиц под конкретные запросы (денормализация). Консистентность настраивается на уровне запросов (через параметры вроде QUORUM, ONE, ALL) с поддержкой конечной согласованности (eventual consistency) и механизмами согласования (read repair, hinted handoff). Cassandra обеспечивает линейную масштабируемость: добавление узлов увеличивает пропускную способность без простоев, а отказоустойчивость достигается через репликацию данных на несколько узлов и дата-центров. Хранилище основано на структурах SSTable (Sorted String Table) с записью в журнал логов для обеспечения долговременного хранения данных, компрессией данных и периодическую компактификацию для удаления устаревших записей.
Безопасность включает аутентификацию (LDAP, Kerberos), ролевое управление доступом (RBAC), шифрование данных на диске и в сети (TLS), аудит операций. Интеграция с экосистемой: поддержка Apache Spark, Hadoop, Kafka через коннекторы, мониторинг через JMX или инструменты вроде Prometheus. Оптимизирована для time-series данных, IoT-платформ, реальной аналитики и сценариев с экстремальной нагрузкой на запись (до миллионов операций/сек на кластере), сохраняя предсказуемую производительность при росте данных.
InfluxDB
InfluxDB использует модель данных, ориентированную на временные метки, и оптимизирована для быстрой записи и выполнения временных запросов.
InfluxDB — специализированная временная БД, разработанная для обработки временных рядов (time-series data) с высокой скоростью приема и запроса данных, таких как метрики мониторинга, IoT-сенсоры или телеметрия. Данные организуются в формате логической группы данных, где каждая запись включает временную метку, тэги (индексируемые метаданные для фильтрации; например, идентификатор устройства), fields (поля, не индексируемые значения, такие, как числа или строки) и опциональные поля с комментариями.
Модель хранения данных оптимизирована через механизм Time-Structured Merge Tree (TSM), который группирует данные по времени, обеспечивая эффективное сжатие (алгоритмами вроде Gorilla для чисел), быструю запись в WAL (write-ahead log) и фоновое слияние SST-подобных файлов для минимизации операций ввода-вывода.
Запросы выполняются на языке InfluxQL (SQL-подобный синтаксис с функциями агрегации MEAN(), SUM() и временными интервалами GROUP BY TIME()), а также на языке Flux (расширенный DSL для ETL-операций, оконных функций и объединений данных от разных источников). Для управления жизненным циклом данных могут использоваться Retention Policies, автоматически удаляющие данные старше заданного срока, и Continuous Queries для предварительной агрегации.
InfluxDB поддерживает горизонтальное масштабирование через InfluxDB Enterprise (кластерная версия с реплицированием данных по времени или по тегам и распределенными запросами). Отказоустойчивость достигается через репликацию данных в кластере и механизм восстановления из так называемых снимков (snapshots).
Безопасность включает аутентификацию (JWT, OAuth 2.0), TLS-шифрование трафика, RBAC с тонкой настройкой прав доступа к измерениям и API-ключам, а также аудит действий.
Интеграция с экосистемой: сбор данных через Telegraf (агент для сбора и агрегации метрик), визуализация в Grafana, обработка потоков через Apache Kafka или RabbitMQ. InfluxDB оптимизирована и протестирована для сценариев с миллионами записей в секунду, долгосрочным хранением метрик (сжатие до 90%) и аналитикой в реальном времени (оконные функции, прогнозирование через встроенные ML-алгоритмы). Отличия от классических NoSQL-систем — отсутствие поддержки транзакций, акцент на временных интервалах в запросах и глубокие интеграции с мониторинговыми стеками (Prometheus, Kubernetes).
CrateDB
CrateDB — распределённая БД, оптимизированная для аналитики в реальном времени и работы с большими объемами структурированных и полуструктурированных данных (например, JSON), особенно в сценариях IoT, промышленного мониторинга и лог-анализа.
Данные хранятся в гибридной модели, сочетающей реляционные таблицы и документную схему: поддерживаются типы данных SQL (INTEGER, TEXT, ARRAY) и вложенные JSON-объекты, при этом динамические поля индексируются автоматически. Архитектура кластера построена на основе децентрализованных узлов (shared-nothing), где данные партиционируются по разделам с использованием хэш- или диапазонного распределения, а репликация (настраиваемый коэффициент) обеспечивает отказоустойчивость через механизм автоматического восстановления при сбоях узлов.
Запросы выполняются на стандартном диалекте SQL с расширениями для полнотекстового поиска (используется движок Apache Lucene), геопространственных операций (индексы на основе R-деревьев), оконных функций и агрегаций в реальном времени (например, COUNT DISTINCT с приближёнными алгоритмами HyperLogLog).
Для распределённого выполнения запросов CrateDB применяет параллельную обработку с push-down логикой, минимизируя перемещение данных между узлами, а оптимизатор запросов автоматически переписывает сложные JOIN-ы в эффективные подзапросы, хотя JOIN-операции между большими таблицами ограничены из-за распределённой природы. Индексы включают B-деревья для точечных запросов, полнотекстовые индексы для текстового анализа и columnar-хранилище для быстрой агрегации. Хранение данных реализовано через самоуправляемые BLOB-объекты с поддержкой сжатия (Zstandard, LZ4) и механизмом автоматического управления версиями данных.
Безопасность включает аутентификацию (LDAP, OAuth 2.0), TLS-шифрование, RBAC на уровне таблиц и аудит действий. Интеграция с экосистемой: встроенные коннекторы для Apache Kafka (ввод/вывод потоковых данных), PostgreSQL-совместимый протокол, библиотеки для Python, Java, а также визуализация через Grafana или Tableau.
CrateDB оптимизирована для высокопроизводительных запросов с низкой задержкой (миллисекунды на петабайтах данных), горизонтального масштабирования и гибридных рабочих нагрузок (OLAP + ограниченный OLTP). Отличия от классических SQL-систем — отсутствие транзакций ACID (только обеспечение атомарности на уровне отдельных операций), акцент на распределённую обработку и гибкость схемы данных, а также встроенная поддержка машинного обучения через интеграцию с библиотеками (например, TensorFlow) для возможности подключения прогнозирования непосредственно в запросах.
TimescaleDB
TimescaleDB — реляционная БД, специализированная для обработки временных рядов, построенная как расширение PostgreSQL, что позволяет сочетать производительность временных баз данных с полнотой SQL и ACID-транзакциями. Данные организуются в гипертаблицы (hypertables) — абстракцию, автоматически разбивающую таблицы по времени (обязательный столбец TIMESTAMP или TIMESTAMPTZ) и дополнительным пространственным ключу (например, идентификатор устройства), разбивая данные на чанки (chunks) — независимые таблицы в PostgreSQL, управляемые через политики хранения (retention policies) и сжатия.
Каждый чанк индексируется B-деревьями, BRIN-индексами (для временных диапазонов) или специализированными индексами TimescaleDB (TSM-сегменты), оптимизированными для временных запросов. Запросы выполняются на стандартном SQL с расширениями для временной аналитики: функции time_bucket() (агрегация по временным интервалам), FIRST()/LAST() (быстрый доступ к крайним точкам), непрерывные агрегаты (continuous aggregates) — материализованные представления, автоматически обновляемые в реальном времени, и прогнозирование через интеграцию с ML-библиотеками.
Для масштабирования используется распределённая версия (TimescaleDB Multi-node): чанки шардируются по узлам кластера с репликацией, а координатор перенаправляет запросы, минимизируя межузловой трафик. Встроенное сжатие (алгоритмы типа Gorilla, Delta-of-Delta) сокращает объём данных до 90%, а иерархическое хранение (tiered storage) позволяет перемещать чанки между SSD и HDD на основе возраста данных.
Безопасность наследует механизмы PostgreSQL: SSL/TLS-шифрование, ролевой доступ (RBAC), аудит через pgAudit и поддержка LDAP/Kerberos. Интеграция с экосистемой: совместимость с любыми PostgreSQL-драйверами, BI-инструментами (Grafana, Tableau), потоковой обработкой через Kafka/Pulsar (нативные коннекторы) и геопространственными запросами через PostGIS. Оптимизирована для сценариев с высокой скоростью приема данных (миллионы записей в секунду на узел), долгосрочным хранением (петабайты) и сложными аналитическими запросами (оконные функции, JOIN с реляционными таблицами). Отличия от классических временных СУБД — полная поддержка SQL (включая транзакции, оконные функции), гибридная модель «время + реляционные данные» и отсутствие привязки к схеме при сохранении ACID-гарантий.
Перейдём к нашему рабочему инструменту, PostgreSQL.
Преимущества PostgreSQL
Каскадные триггеры. Если триггерная функция выполняет команды SQL, эти команды могут заново запускать триггеры. Насколько я знаю, каскадные триггеры есть и в SQL Server.
hstore. Возможность создавать и манипулировать данными с функциональностью словаря (dictionary).
JSONB. Парсинг JSON осуществляется однократно, во время записи. Более медленная однократная запись, но более быстрые многократные чтения. По умолчанию рекомендуется использовать JSONB, а не JSON.
Range types. Никаких больше колонок planned_worktime_start и planned_worktime_end и пляски с операторами сравнения для нахождения других строк, у которых интервал, задаваемый этими колонками пересекается с этой строкой.
Другие удобные нативные типы: interval, macaddr и другие, со встроенными методами работы с ними.
Массивы — нарушение 1-й нормальной формы, но когда всё, что необходимо — это сохранить несколько строчек, то добавление отдельной таблицы с перспективой JOIN'а с ней выглядит совсем непривлекательно.
У PostgreSQL полностью транзакционный DDL, т.е. можно в транзакциях менять схему данных, и эти изменения буду транзакционными. Соответственно, возможны миграции без остановки записи.
PostGIS. Бесплатное расширение для PostgreSQL с открытым исходным кодом для работы с географическими объектами, дополняющее встроенные возможности БД (point, gist). Работает с точками, ломаными линиями, полигонами, растрами, а также использует их для разных операций, например, поиска.
PL/pgSQL. Процедурный язык для PostgreSQL. Функции PL/pgSQL могут использоваться везде, где допустимы встроенные функции. Например, можно создать функции со сложными вычислениями и условной логикой, а затем использовать их при определении операторов или в индексных выражениях.
Типы данных PostgreSQL
Целые числа
smallint 2 байта
integer 4 байта Самый распространенный целочисленный тип
bigint 8 байт
Автоинкрементируемые целые числа
smallserial 2 байта
serial 4 байта
bigserial 8 байт
Фактически, serial — не истинный тип, а "синтаксический сахар" для замены группы SQL-команд.
Синтаксис для создания столбца типа serial таков:
CREATE TABLE имя-таблицы (имя-столбца serial);
Эта команда эквивалентна следующей группе команд:
CREATE SEQUENCE имя-таблицы_имя-столбца_seq;
CREATE TABLE имя-таблицы
(
имя-столбца integer NOT NULL
DEFAULT nextval('имя-таблицы_имя-столбца_seq')
);
ALTER SEQUENCE имя-таблицы_имя-столбца_seq
OWNED BY имя-таблицы.имя-столбца;
Здесь используется функция nextval, которая получает очередное число из последовательности (по сути, из генератора уникальных целых чисел).
Вещественные числа
float4 (real) 4 байта 6 знаков после запятой Неточное значение, накопление ошибок при проведении арифметических операций
float8 (float, double precision) 8 байт 15 знаков после запятой Неточное значение
Типы данных real и double precision поддерживают специальное значение NaN, которое означает «не число» (not a number). В математике существует такое понятие, как неопределенность. В качестве одного из ее вариантов служит результат операции умножения нуля на бесконечность. Посмотрите, что выдаст в результате PostgreSQL: SELECT 0.0 * 'Inf'::real;
NaN (1 строка)
Значение NaN считается равным другому значению NaN, а также что значение NaN считается большим любого другого «нормального» значения, т. е. не-NaN.
Например, можно сравнить значения NaN и Infinity.
SELECT 'NaN'::real > 'Inf'::real;
decimal (numeric) Точное значение. Денежные рассчеты
Символы
char строка фиксированной длины, дополняется пробелами
varchar изменяемой длины с лимитом
text нелимитируемой длины
Документация рекомендует использовать типы text и varchar, поскольку такое отличительное свойство типа character, как дополнение значений пробелами, на практике почти не востребовано. В PostgreSQL обычно используется тип text.
Для экранирования символов, например, одинарной кавычки или обратной косой черты, их удваивают. Если экранируемых символов слишком много и это затрудняет чтение текста, строку можно одинарных ковычек заключить в двойные символы «$».
PostgreSQL предлагает еще одно расширение стандарта SQL — строковые константы в стиле языка C. Чтобы иметь возможность их использовать, нужно перед начальной одинарной кавычкой написать символ «E». Например, для включения в константу символа новой строки нужно сделать так:
SELECT E'Hello\nworld!';
Логическое значение
Boolean (bool) 1 байт
В качестве истинного состояния могут служить следующие значения: TRUE, 't', 'true', 'y', 'yes', 'on', '1'.
В качестве ложного состояния могут служить следующие значения: FALSE, 'f', 'false', 'n', 'no', 'off', '0'.
При работе с логическими значениями в условии WHERE можно не писать выражение WHERE is_value = 'yes', а достаточно просто указать WHERE is_value.
Представление даты и времени
date. По умолчанию используется формат, рекомендуемый стандартом ISO 8601: «yyyy-mm-dd».
Чтобы «сказать» СУБД, что введенное значение является датой, а не простой символьной строкой, нужно использовать операцию приведения типа. В PostgreSQL она оформляется с использованием двойного символа двоеточия и имени того типа, к которому мы приводим данное значение. Важно учесть, что при выполнении приведения типа производится проверка значения на соответствие формату целевого типа и множеству его допустимых значений.
SELECT 'Sep 01, 2023'::date;
Можно использовать команду получения текущей даты:
SELECT current_date;
time
time with time zone
Время вводить как в 12-часовом, так и в 24-часовом формате:
SELECT '22:15:10'::time;
SELECT '10:15:10 pm'::time;
Текущее время:
SELECT current_time;
Типы timestamp, time и interval позволяют задать точность ввода и вывода значений. Точность предписывает количество десятичных цифр в поле секунд. Проиллюстрируем эту возможность на примере типа time, выполнив три запроса: в первом запросе вообще не используем параметр точности, во втором назначим его равным 0, в третьем запросе сделаем его равным 3.
SELECT current_time;
timetz
19:46:14.584641+03 (1 строка) SELECT current_time::time( 0 );
time
19:39:45 (1 строка) SELECT current_time::time( 3 );
time
19:39:54.085 (1 строка)
timestamp. временная метка — объединение типов даты и времени.
timestamp with time zone (timestamptz)
Получение текущей временной метки:
SELECT current_timestamp;
Значения временных меток можно округлять с помощью функции date_trunc. Например, для получения текущей временной отметки с точностью до одного часа нужно сделать так:
SELECT (date_trunc('hour', current_timestamp));
Из значений временных отметок можно с помощью функции extract извлекать отдельные поля, т. е. год, месяц, день, число часов, минут или секунд и т. д. Например, чтобы извлечь номер месяца, нужно сделать так:
SELECT extract('mon' FROM timestamp '2023-11-21 12:35:555.123456');
interval Разница между двумя timestamp
Например:
SELECT '1 year 6 months ago'::interval;
или
SELECT ('2021-08-16'::timestamp - '2021-08-01'::timestamp)::interval;
Формат ввода и вывода даты можно изменить с помощью конфигурируемого параметра datestyle. Значение этого параметра состоит из двух компонентов: первый управляет форматом вывода даты, а второй регулирует порядок следования составных частей даты (год, месяц, день) при вводе и выводе. Текущее значение этого параметра можно узнать с помощью команды SHOW:
SHOW datestyle;
Массивы
PostgreSQL позволяет создавать в таблицах такие столбцы, в которых будут содержаться не отдельные значения, а массивы переменной длины. Эти массивы могут быть многомерными и могут содержать значения любого из встроенных типов, а также типов данных, определенных пользователем.
Для указания на то, что это массив, нужно добавить квадратные скобки к наименованию типа данных, при этом задавать число элементов не обязательно.
Пример:
CREATE TABLE gamers
(
name text,
points integer[]
);
INSERT INTO gamers
VALUES ( 'Ivan', '{ 1, 3, 5, 6, 7 }'::integer[] ),
( 'Mike', '{ 1, 2, 5, 7 }'::integer[] ),
( 'Jake', '{ 2, 5 }'::integer[] ),
( 'Finn', '{ 3, 5, 6 }'::integer[] )
Добавление значения в массив:
UPDATE gamers
SET points = points || 8
WHERE name = 'Jake';
json, jsonb
Типы JSON предназначены для сохранения в столбцах таблиц базы данных таких значений, которые представлены в формате JSON (JavaScript Object Notation). Существует два типа: json и jsonb. Основное различие между ними заключается в быстродействии. Если столбец имеет тип json, тогда сохранение значений происходит быстрее, потому что они записываются в том виде, в котором были введены. Но при последующем использовании этих значений в качестве операндов или параметров функций будет каждый раз выполняться их разбор, что замедляет работу. При использовании типа jsonb разбор производится однократно, при записи значения в таблицу. Это несколько замедляет операции вставки строк, в которых содержатся значения данного типа. Но все последующие обращения к сохраненным значениям выполняются быстрее, т. к. выполнять их разбор уже не требуется.
Есть еще ряд отличий, в частности, тип json сохраняет порядок следования ключей в объектах и повторяющиеся значения ключей, а тип jsonb этого не делает. Рекомендуется в приложениях использовать тип jsonb, если только нет каких-то особых аргументов в пользу выбора типа json.
NULL — отсутствие данных
Есть также XML, геометрические типы, типы, определяемые пользователем и прочее.
DDL
Constraints (ограничения)
column_constraint is:
[ CONSTRAINT constraint_name ]
{ NOT NULL |
NULL |
CHECK ( expression ) [ NO INHERIT ] |
DEFAULT default_expr |
GENERATED ALWAYS AS ( generation_expr ) STORED |
GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY [ ( sequence_options ) ] |
UNIQUE [ NULLS [ NOT ] DISTINCT ] index_parameters |
PRIMARY KEY index_parameters |
REFERENCES reftable [ ( refcolumn ) ] [ MATCH FULL | MATCH PARTIAL | MATCH SIMPLE ]
[ ON DELETE referential_action ] [ ON UPDATE referential_action ] }
[ DEFERRABLE | NOT DEFERRABLE ] [ INITIALLY DEFERRED | INITIALLY IMMEDIATE ]
and table_constraint is:
[ CONSTRAINT constraint_name ]
{ CHECK ( expression ) [ NO INHERIT ] |
UNIQUE [ NULLS [ NOT ] DISTINCT ] ( column_name [, ... ] ) index_parameters |
PRIMARY KEY ( column_name [, ... ] ) index_parameters |
EXCLUDE [ USING index_method ] ( exclude_element WITH operator [, ... ] ) index_parameters [ WHERE ( predicate ) ] |
FOREIGN KEY ( column_name [, ... ] ) REFERENCES reftable [ ( refcolumn [, ... ] ) ]
[ MATCH FULL | MATCH PARTIAL | MATCH SIMPLE ] [ ON DELETE referential_action ] [ ON UPDATE referential_action ] }
[ DEFERRABLE | NOT DEFERRABLE ] [ INITIALLY DEFERRED | INITIALLY IMMEDIATE ]
Constraints указываются при создании или изменении таблицы. Эти правила ограничивают тип данных, которые могут храниться в таблице. При нарушении ограничений действия с данными выполнены не будут.
NOT NULL — значение не может быть NULL;
CHECK — значения столбца должны соответствовать заданным условиям.
DEFAULT — назначает столбцу значение по умолчанию.
UNIQUE — гарантирует уникальность значений в столбце.
PRIMARY KEY
внешний ключ (foreign key)
Exclusion Constraints
CREATE, создание БД и таблиц
Создание новой БД:
CREATE DATABASE testdb
WITH
OWNER = postgres
ENCODING = 'UTF8';
Создание новых таблиц:
CREATE TABLE publisher
(
publisher_id integer PRIMARY KEY,
org_name varchar(128) NOT NULL,
address text NOT NULL
);
CREATE TABLE book
(
book_id integer PRIMARY KEY,
title text NOT NULL,
isbn varchar(32) NOT NULL
)
PRIMARY KEY (первичный ключ) предназначен для однозначной идентификации каждой записи в таблице и является строго уникальным, две записи таблицы не могут иметь одинаковые значения первичного ключа. Нулевые значения (NULL) в PRIMARY KEY не допускаются. Если в качестве PRIMARY KEY используется несколько полей, их называют составным ключом.
DROP, удаление таблицы
DROP TABLE publisher;
DROP TABLE book
ALTER, изменение структуры таблицы
Добавляем колонку:
ALTER TABLE book
ADD COLUMN fk_publisher_id integer;
FOREIGN KEY, внешний ключ
Сообщаем о том, что эта колонка будет внешним ключом, ссылающимся на publisher_id в другой таблице
ALTER TABLE book
ADD CONSTRAINT fk_book_publisher
FOREIGN KEY (fk_publisher_id) REFERENCES publisher(publisher_id)
Структура таблицы будет приведена к виду, как если бы мы создавали её при помощи следующих команд:
CREATE TABLE book
(
book_id integer PRIMARY KEY,
title text NOT NULL,
isbn varchar(32) NOT NULL,
fk_publisher_id integer REFERENCES publisher(publisher_id) NOT NULL
)
Мы создали отношение «один ко многим» (одно издательство может издавать множество книг), самое часто используемое отношение в SQL.
RENAME
TRUNCATE
CREATE INDEX
создаёт индексы в таблице для быстрого поиска/запросов;
Общая информация Индексы по нескольким столбцам Уникальные индексы Индексы на основе выражений Частичные индексы
Работа с индексами и ограничениями
Индексы
Индекс – специальная структура данных, которая связана с таблицей и создаётся на основе её данных. Индексы создаются для повышения производительности функционирования базы данных.
Какие бывают индексы?
В-дерево
хеш
GiST
SP-GiST
GIN
BRIN
По умолчанию команда CREATE INDEX создаёт индексы типа В-дерево (эффективны в большинстве случаев)
Как можно создать индексы?
• Индекс по столбцу (это чистая классика)
• Индекс по нескольким столбцам
• Уникальный индекс
• Индекс на основе выражения
• Частичный индекс
Для создания индекса используется примерно такой синтаксис:
CREATE [UNIQUE] INDEX
для создания уникального индекса может использоваться слово UNIQUE
для создания выражения его записывают в скобках, например для создания выражения проверки индекса на нижний регистр можно написать так:
. . . ( lower(
. . . ( . . . ) WHERE
CREATE VIEW, Представления
При работе с базами данных зачастую приходится многократно выполнять одни и те же запросы, которые могут быть весьма сложными и требовать обращения к нескольким таблицам. Чтобы избежать необходимости многократного формирования таких запросов, можно использовать так называемые представления (views). Если речь идет о выборке данных, то представления практически неотличимы от таблиц с точки зрения обращения к ним в командах SELECT.
Упрощенный синтаксис команды CREATE VIEW, предназначенной для создания представлений:
CREATE VIEW имя-представления [ ( имя-столбца [, ...] ) ]
AS запрос;
Полный синтаксис:
CREATE [ OR REPLACE ] [ TEMP | TEMPORARY ] [ RECURSIVE ] VIEW name [ ( column_name [, ...] ) ]
[ WITH ( view_option_name [= view_option_value] [, ... ] ) ]
AS query
[ WITH [ CASCADED | LOCAL ] CHECK OPTION ]
В этой команде обязательными элементами являются имя представления и запрос к базе данных, который и формирует выборку из нее. Если список имен столбцов не указан, тогда их имена «вычисляются» (формируются) на основании текста запроса.
Давайте создадим простое представление. В главе 3 мы решали задачу: подсчитать количество мест в салонах для всех моделей самолетов с учетом класса обслуживания (бизнес-класс и экономический класс). Запрос был таким:
SELECT aircraft_code,
fare_conditions,
count( * )
FROM seats
GROUP BY aircraft_code, fare_conditions
ORDER BY aircraft_code, fare_conditions;
На его основе создадим представление и дадим ему имя, отражающее суть этого представления.
CREATE VIEW seats_by_fare_cond AS
SELECT aircraft_code,
fare_conditions,
count( * )
FROM seats
GROUP BY aircraft_code, fare_conditions
ORDER BY aircraft_code, fare_conditions;
Теперь мы можем вместо написания сложного первоначального запроса обращаться непосредственно к представлению, как будто это обычная таблица.
SELECT * FROM seats_by_fare_cond;
В отличие от таблиц, представления не содержат данных. При каждом обращении к представлению в команде SELECT данные выбираются из таблиц, на основе которых это представление создано.
Материализованное представление
Упрощенный синтаксис команды CREATE MATERIALIZED VIEW, предназначенной для создания материализованных представлений, таков:
CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] имя-мат-представления
[ ( имя-столбца [, ...] ) ]
AS запрос
[ WITH [ NO ] DATA ];
В момент выполнения команды создания материализованного представления оно заполняется данными, но только если в команде не было фразы WITH NO DATA. Если же она была включена в команду, тогда в момент своего создания представление остается пустым, а для заполнения его данными нужно использовать команду REFRESH MATERIALIZED VIEW.
Материализованное представление очень похоже на обычную таблицу. Однако оно отличается от таблицы тем, что не только сохраняет данные, но также запоминает запрос, с помощью которого эти данные были собраны.
DML
INSERT, добавление новых строк
Insert INTO book
VALUES
(1, 'The Diary of a Young Girl', '0199535566', 1),
(2, 'Pride and Prejudice', '9780307594006', 1),
(3, 'To Kill a Mockingbird', '0446310786', 2),
(4, 'The book of a Gutsy Women: Favorite Stories of Courage and Resilience', '1501178415', 2),
(5, 'War and Peace', '1788886526', 2);
Insert INTO publisher
VALUES
(1, 'Everyman''s Library', 'NY'),
(2, 'Oxford University Press', 'NY'),
(3, 'Grand Central Publishing', 'Washington'),
(4, 'Simon & Schuster', 'Chicago');
Проверка правильности добавленных строк (более подробно оператор SELECT будет рассмотрен ниже):
SELECT *
FROM book
и
SELECT *
FROM publisher
WHERE и HAVING могут использоваться в одном запросе, при этом необходимо учитывать порядок исполнения SQL запроса:
- FROM
- ON
- JOIN
- WHERE
- GROUP BY
- WITH CUBE / WITH ROLLUP
- HAVING
- Оконные функции
- SELECT
- DISTINCT
- UNION / UNION ALL / INTERSECT / EXCEPT
- ORDER BY
- TOP / LIMIT / OFFSET
Сначала определяется таблица, из которой выбираются данные (FROM);
затем из этой таблицы отбираются записи в соответствии с условием WHERE;
выбранные данные агрегируются (GROUP BY);
из агрегированных записей выбираются те, которые удовлетворяют условию после HAVING.
Только потом формируются данные результирующей выборки, как это указано после SELECT (вычисляются выражения, присваиваются имена и пр.). Результирующая выборка сортируется в соответствии с условием, указанным после ORDER BY.
Знание порядка исполнения SQL запроса важно для того, чтобы понять, почему, например, внутри WHERE невозможно использовать имена выражений из SELECT; просто SELECT выполняется компилятором позже, чем WHERE, поэтому ему неизвестно, какое выражение там описано.
SELECT, FROM, WHERE
Команда SELECT получает записи из базы данных по определенному условию, которое задается с помощью команды WHERE.
Синтаксис:
SELECT FROM имя_таблицы;
SELECT FROM имя_таблицы WHERE условие;
SELECT поле1, поле2... FROM имя_таблицы WHERE условие;
Полный синтаксис команды SELECT:
SELECT
[STRAIGHT_JOIN] [SQL_SMALL_RESULT] [SQL_BIG_RESULT] [SQL_BUFFER_RESULT]
[SQL_CACHE | SQL_NO_CACHE] [SQL_CALC_FOUND_ROWS] [HIGH_PRIORITY]
[DISTINCT | DISTINCTROW | ALL]
select_expression,...
[INTO {OUTFILE | DUMPFILE} 'file_name' export_options]
[FROM table_references
[WHERE where_definition]
[GROUP BY {unsigned_integer | col_name | formula} [ASC | DESC], ...]
[HAVING where_definition]
[ORDER BY {unsigned_integer | col_name | formula} [ASC | DESC], ...]
[LIMIT [offset,] rows | rows OFFSET offset]
[PROCEDURE procedure_name(argument_list)]
[FOR UPDATE | LOCK IN SHARE MODE]]
LIKE
ORDER BY
Сортировка данных в порядке возрастания (ASC) или убывания (DESC).
SELECT * FROM user ORDER BY name DESC;
COUNT, SUM, AVG, MAX, MIN, агрегирующие функции
GROUP BY, HAVING, группировка и фильтрация данных
JOIN, объединение данных из нескольких таблиц
INNER JOIN
Если совсем кратко, то INNER JOIN не пересечение множеств, как нарисовано, а, скорее, перемножение с условием.
FULL OUTER JOIN
LEFT JOIN
RIGHT JOIN
CROSS JOIN, перекрестное соединение
Создает набор строк, где каждая строка из одной таблицы соединяется с каждой строкой из второй таблицы.
Self join
Такой вопрос тоже может прозвучать на собеседовании по SQL. Это выражение используется для того, чтобы таблица объединилась сама с собой, словно это две разные таблицы. Чтобы такое реализовать, одна из таких «таблиц» временно переименовывается.
Например, следующий SQL-запрос объединяет клиентов из одного города:
SELECT A.CustomerName AS CustomerName1, B.CustomerName AS CustomerName2, A.City
FROM Customers A, Customers B
WHERE A.CustomerID <> B.CustomerID
AND A.City = B.City
ORDER BY A.City;
UPDATE, изменение существующих строк
DELETE, удаление строк
Операция, которая удаляет записи из таблицы, соответствующие заданному условию. При этом создаются логи удаления, то есть операцию можно отменить.
DELETE FROM table_name WHERE condition;
FROM
SET
Вложенные запросы
SQL позволяет создавать вложенные запросы. Вложенный запрос – это запрос, размещенный внутри другого запроса SQL.
Вложенный запрос используется для выборки данных, которые будут использоваться в условии отбора записей основного запроса. Его применяют для:
сравнения выражения с результатом вложенного запроса;
определения того, включено ли выражение в результаты вложенного запроса;
проверки того, выбирает ли запрос определенные строки.
Вложенный запрос имеет следующие компоненты:
ключевое слово SELECT, после которого указываются имена столбцов или выражения (чаще всего список содержит один элемент),
ключевое слово FROM и имя таблицы, из которой выбираются данные,
необязательное предложение WHERE,
необязательное предложение GROUP BY,
необязательное предложение HAVING.
опросы на собеседованиях очень часто бессистемные, они могут быть просто о том, что вопрошающему конкретно сейчас пришло в голову, и он вообще не факт, что сам умеет спрашивать о главном, а не второстепенном. Приведу только один пример у вас — про индексы. Вас, видимо, спрашивали о том, какие они бывают и каким синтаксисом создаются. Но поверьте, это совсем не самое главное из того, что стоит знать про индексы. Гораздо важнее понимать их суть, что они из себя представляют внутри — чтобы понимать, почему какой-то запрос индексом ускоряется, а какой-то нет.
Изучение индексов для ускорения поиска данных в таблице
Изучение ограничений для защиты данных и обеспечения целостности таблицы
Работа с представлениями и хранимыми процедурами
Изучение представлений для создания виртуальных таблиц на основе запросов
Изучение хранимых процедур для создания пользовательских функций и процессов
Продвинутые SQL скиллы:
MVCC
Оптимизация запросов — знание структуры таблиц и индексов, а также понимания того, как оптимизировать запросы для улучшения производительности.
Повышение производительности. Основные понятия. Методы просмотра таблиц. Методы формирования соединений наборов строк. Управление планировщиком. Оптимизация запросов.
Работа с большими объемами данных — управление партиционированием, кластеризацией и другими методами для обработки и анализа больших объемов данных.
Использование аналитических функций — использование функций, таких как RANK, ROW_NUMBER, LAG и LEAD, для выполнения сложных аналитических запросов.
Работа с временными рядами данных — использование функций временных рядов, таких как DATE_TRUNC, DATE_PART и WINDOW функций, для анализа и управления временными рядами данных.
Работа с географическими данными — использование специальных функций, таких как ST_Distance, ST_Within и ST_Intersection, для анализа и управления географическими данными.
Работа с хранилищами данных — использование функций ETL (Extract, Transform, Load) для извлечения, преобразования и загрузки данных в хранилища данных.
Работа с процедурами и триггерами — создание и управление процедурами и триггерами для автоматизации задач и обеспечения целостности данных.
Работа с реляционной алгеброй — использование различных операторов, таких как JOIN, UNION, INTERSECT и EXCEPT, для выполнения сложных запросов.
Работа с индексами — создание и управление индексами для улучшения производительности запросов.
Работа с безопасностью данных — управление доступом к данным и защиту данных от несанкционированного доступа.
Оконные функции
Вложенные транзакции
Механизм, который неявно задействован при создании точек сохранения и при обработке исключений.
Что такое курсор и зачем он нужен?
Что делает оператор JOIN, какие виды бывают?
Что делает оператор HAVING, примеры?
В каких случаях вы бы предпочли нереляционную БД?
Что такое функциональный индекс?
VACUUM
Команда VACUUM высвобождает пространство, занимаемое «мертвыми» кортежами, что актуально для часто используемых таблиц. При обычных операциях в Postgres кортежи, удаленные или устаревшие в результаты обновления, физически не удаляются, а сохраняются в таблице до очистки.
EXPLAIN, EXPLAIN ANALYZE
EXPLAIN ANALYZE – в отличие от просто EXPLAIN не только показывает план выполнения запроса, но и непосредственно выполняет запрос и показывает реальное время выполнения.
Server side cursor
Способ работы с результатом запроса в базу данных, который позволяет не загружать весь объем данных в память, позволяет работать с большими объемами данных. Дополнительно углубленно можно поговорить про особенности работы в связке с pgbouncer.
Это любители mysql принесли 8 главу своей документации, где эта штука у них называется optimizer и состоит из нескольких частей (query planner, собственно query optimizer и т.д.)
В postgresql (и других базах) это называют planner и в русскоязычном IT в аспекте БД (не только postgres) принято слово именно что "планировщик". Ну а то, что некоторые любители mysql словом "планировщик" с давних пор называют почему-то event/job scheduler, вас в общем случае волновать не должно, это у них там своя атмосфера.
Это всё издержки переноса англоязычной терминологии на русский язык. Каким образом так случилось, что в русском scheduler стал планировщиком, я внятно объяснить не могу.
Schedule — это вообще-то график (дежурств), расписание (действий). Планирование как составление плана действий, здесь отсутствует, расписание — продукт планирования, а не процесс. И scheduler в этом смысле — это исполнитель расписания, а не его составитель.
Plan — это в некотором смысле алгоритм, последовательность шагов при исполнении какого-либо действия (из, например, расписания).
Optimize — в общем случае улучшение этого самого Plan.
Подытожим:
scheduler — хранит таблицу, расписание выполнения задач, и обеспечивает их запуск по этому расписанию;
planner — составляет план действий для исполнения конкретного запроса (а EXPLAIN его, этот план, показывает);
optimizer — часть planner'а (может и отсутствовать). Например, переупорядочивает действия, исключает повторные действия, и т.п.
Таким образом получается, что именно в PostgreSQL всё как раз таки и называется правильно.
Источники
Е. П. Моргунов. PostgreSQL. Основы языка SQL.
10. Сетевые возможности
Интернет настолько хорош, что большинство людей считает его природным ресурсом, таким, как Тихий океан, а не чем-то сотворенным руками человека.
Алан Кей.

Прежде чем перейти к частностям, таким, скажем, как создание и эксплуатация REST-сервисов, давайте сначала пробежимся по верхам принципов сетевого взаимодействия компьютеров.
Простота конструкции «кликнул на ссылку в Google — посмотрел видео на Youtube» обманчива, и когда что-то пойдёт не так (а если вы — разработчик, то рано или поздно что-то пойдёт не так обязательно), вам нужно быть готовым более-менее ориентироваться в глубинах глубин сетевых протоколов.
К тому же, навык работы с сетью можно отнести к триаде ключевых требований к бекэнд-разработчику (хорошее знание используемого языка программирования + уверенная работа с БД, как минимум с SQL + чёткое понимание специфики работы с сетью). Так что постарайтесь уделить изучению работы с сетью вообще и сетевых возможностей Pyhon в частности самое пристальное внимание.
Стек TCP/IP
Для того чтобы переслать пакет информации (скажем, картинку) из, например, североамериканского Бостона в российский Воронеж по общим сетям передачи данных через мощные напластования разнородного оборудования, необходим общепонятный стек протоколов, объемлющий всю транспортную цепочку трансфера цифровых данных, формализующий правила формирования и транспорта пакетов. Стек протоколов должен охватывать все ступени модели передачи данных, начиная от уровня, видимого пользователю (это, например, HTTP запросы в браузере) и вплоть до уровня, ответственного непосредственно за работу с физической средой передачи данных (например, с магистральным оптоволокном, уложенным на дне Атлантического океана).
Стек протоколов, обеспечивающий сквозную передачи информации по компьютерным сетям вообще и через сеть Интернет в частности, называется стеком протоколов TCP/IP и состоит из четырех уровней: прикладного, транспортного, сетевого и канального.
Самый верхний, прикладной уровень обеспечивает взаимодействие сети и пользователя. Под пользователем тут, разумеется, имеется в виду не человек, а разного рода программное обеспечение и аппаратные средства (например, IoT-датчики), взаимодействующие с сетью. Прикладной уровень — точка входа информации в сеть. Самый известный протокол прикладного уровня — HTTP.
Транспортный уровень — набор механизмов, обеспечивающих нужную последовательность передачи и приёма данных, устранение дублирования, а также, при необходимости, организацию повторных запросов в случае потери данных.
На транспортном уровне работают следующие протоколы:
TCP — протокол, обеспечивающий организацию повторных запросов при потере информации и устраняющий дублирование при получении двух копий одного пакета;
UDP — посылает пакеты без обеспечения целостности данных; нужен для потокового вещания, сетевых компьютерных игр, интернет-телефонии и других приложений, где скорость передачи и сиюминутная актуальность информации важнее контроля за правильностью передачи, а повторные запросы выпавших пакетов часто не имеют смысла;
DCCP — новый протокол, который так же как и UDP, не гарантирует доставку данных, но дополнительно обеспечивает доступ к механизмам контроля перегрузки, предоставляющим информацию о потерянных пакетах; не очень распространен, т. к. механизмы, контролирующие потерю данных в стриминговых сетях, зачастую реализуются на верхнем, прикладном уровне;
SCTP — относительно новый протокол, обеспечивающий расширенные функции безопасности и многопоточную передачу.
Сетевой уровень (в англоязычной литературе зачастую именуемый даже не network layer, а просто internet layer) — уровень работы маршрутизаторов, последовательно пересылающих пакеты из сети в сеть. На сетевом уровне конкретное содержание информационных пакетов не имеет значения, важна только адресация и маршрутизация. Самый важный протокол этого уровня — IP, объединяющий сегменты сети в единое целое и обеспечивающий доставку данных между любыми узлами сети через произвольное число промежуточных маршрутизаторов.
Канальный уровень (или уровень передачи данных) описывает работу сети на физическом уровне и работает с такими низкоуровневыми понятиями, как последовательность бит или помехозащищенность. Например, самый распространённый протокол сетевого уровня, Ethernet, специфицирует специальные битовые последовательности, определяющие начало и конец пакета данных.
Физический уровень, затрагивающий электрические спецификации, официально не входит в стек TCP/IP, но часто упоминается в образовательных целях, например, в «Компьютерных сетях» Эндрю Таненбаума.
Сетевая модель OSI
Модель OSI — еще одна попытка (пожалуй, самая масштабная) создать иерархическую модель стека сетевых протоколов. Во многом пересекается и конкурирует со стеком TCP/IP; TCP/IP мягче и более практичен, OSI более подробна, формальна, и широко используется для обучения. Модель OSI — попытка комитетов ISO создать максимально детальную концепцию организации сетей; это не жесткое требование, не физический закон, а всего лишь набор рекомендаций, подвергающихся, к тому же, непрестанной критике.
Комитеты ISO пытались выстроить стройную иерархию, в которой протоколы модели OSI должны взаимодействовать либо с протоколами своего уровня, либо с протоколами соседнего уровня. Каждый протокол модели OSI может реализовывать только функции своего уровня и не в коем случае не должен выполнять функций другого уровня.
На практике же сплошь и рядом можно встретить несоответствия модели, вроде «протокол SPX работает и на транспортном и на сеансовом уровнях» или «если протокол HTTP используется для просмотра интернет-страницы при помощи браузера, то HTTP — протокол прикладного уровня; если же HTTP используется как транспорт для передачи финансовой информации по протоколу ISO 8583, то протокол HTTP будет являться протоколом уровня представления, а протокол ISO 8583 — протоколом уровня приложения», в этом нет ничего страшного. Модели всего лишь пытаются описать реально применяемые технологии, и делают это не всегда гладко.
В отличие от стека TCP/IP, имеющего четыре уровня, в модели OSI семь уровней. Отличие не такое уж и большое, учитывая то, что при описании TCP/IP к нему часто добавляется пятый, физический уровень, никак не затронутый в оригинальной RFC 1122. Уровни OSI:
Уровень приложений
Верхний уровень модели, обеспечивающий взаимодействие пользовательских приложений с сетью. Пример протоколов прикладного уровня: HTTP, SNMP.
Уровень представления
Уровень отвечает за преобразование данных между форматами, понятными уровню приложений, и форматами, предназначенными для передачи по сети.
Сеансовый уровень
Уровень обеспечивает поддержание сеанса связи, обеспечивая длительное взаимодействие приложений. Сеансовый уровень управляет созданием и завершением сеанса связи, а также поддержанием сеанса в периоды неактивности приложений.
Транспортный уровень
Здесь мы снова встречаем TCP, UDP, SCTP, уже знакомых нам по транспортному уровню стека TCP/IP. Уровень занимается доставкой данных, обеспечивая прогнозируемую надежность, зависящую от конкретного протокола.
Сетевой уровень
Уровень маршрутизации.
Канальный уровень
Как и в стеке TCP/IP, канальный уровень отвечает за работу на физическом уровне, а также за распознавание и коррекцию (если такая возможность предусмотрена протоколом передачи) ошибок физического уровня.
Физический уровень
Уровень, не затронутый TCP/IP и отвечающий за методы передачи цифровой информации от одного устройства к другому. Конкретные электрические и механические спецификации интерфейсов также относятся к этому уровню. К конкретным реализациям физического уровня относятся, например, интерфейсы RS-232, RS-485. Такие чисто программные сущности, как коды NRZ или Манчестер, тоже относятся к физическому уровню.
TCP и UDP
Некоторые чувствительные к задержке программы, например приложения для видеоконференций и IP-телефонии (Voice over IP, VoIP) используют UDP вместо TCP. Это своеобразный компромисс между надежностью и непостоянством задержек: за счет отсутствия в UDP управления потоком данных и повторной отправки потерянных пакетов в этом протоколе удается избежать части причин для широкой вариабельности времени задержки (хотя в нем все равно возможны очереди на коммутаторах и задержки из-за диспетчеризации).
UDP имеет смысл выбирать в случаях, когда запоздавшие данные теряют всякую ценность. Например, при звонке по VoIP нет времени на повторную передачу пакета, прежде чем его данные должны быть воспроизведены через динамики. В таком случае смысла повторять отправку пакета нет — вместо этого приложению следует заполнить соответствующий пакету промежуток времени тишиной (что приведетк краткому прерыванию звука) и продолжить воспроизведение потока данных. Повтор происходит на уровне разговаривающих людей («Не могли бы вы повторить,что вы сказали? Звук только что прервался на секунду».)
HTTP
HTTP – текстовый протокол, работающий поверх TCP/IP. HTTP состоит из запроса и ответа. Их структуры похожи: стартовая строка, заголовки, тело ответа.
Стартовая строка запроса состоит из метода, пути и версии протокола:
GET /index.html HTTP/1.1
Стартовая строка ответа состоит из версии протокола, кода ответа и текстовой расшифровке ответа.
HTTP/1.1 200 OK
Заголовки – это набор пар ключ-значение, например, User-Agent, Content-Type. В заголовках передают метаданные запроса: язык пользователя, авторизацию, перенаправление. Заголовок Host должен быть в запросе всегда.
Тело ответа может быть пустым, либо может передавать пары переменных, файлы, бинарные данные. Тело отделяется от заголовков пустой строкой.
HTTPS
CORS
Что такое CSRF?
Cross Site Request Forgery (межсайтовая подделка запроса). Вид уязвимости, когда сайт А вынуждает пользователя выполнить запрос на сайт Б. Это может быть тег img или script для GET-запроса, или форма со специальным атрибутом target.
Чтобы предотвратить уязвимость, сайт Б должен убедиться, что запрос пришел именно с его страницы.
Например, пользователь должен заполнить форму. В нее помещают скрытое поле token – одноразовую последовательность символов. Этот же токен сохраняют в куки пользователя. При отправке формы поле и куки должны совпасть. Способ не является надежным и обходится скриптом.
REST
REST (Representational state transfer) – соглашение о том, как выстраивать сервисы.
REST не протокол, а скорее подход к проектированию, основанный на принципах HTTP. Он делает акцент на простых форматах данных, применении URL для идентификации ресурсов и использовании возможностей HTTP для управления кэшем, аутентификации и согласования типа контента. REST становится все более популярным по сравнению с SOAP (основанный на формате XML протокол для выполнения запросов к сетевым API), и часто ассоциируется с микросервисами.
Под REST часто имеют в виду т.н. HTTP REST API. Как правило, это веб-приложение с набором урлов – конечных точек. Урлы принимают и возвращают данные в формате JSON. Тип операции задают методом HTTP-запроса, например:
GET – получить объект или список объектов POST – создать объект PUT – обновить существующий объект DELETE – удалить объект HEAD – получить метаданные объекта
REST-архитектура активно использует возможности протокола HTTP, чтобы избежать т.н. “велосипедов” – собственных решений. Например, параметры кеширования передаются стандартными заголовками Cache, If-Modified-Since, ETag. Авторизация – заголовком Authentication.
Воплощающие REST API имеют предрасположенность к более простым подходам, включающим обычно генерацию меньшего количества кода и использование автоматизированных утилит. Для описания воплощающих REST API и создания документации можно применить такой формат описания, как OpenAPI, известный также под названием Swagger.
Не существует каких-либо соглашений о том, как должен функционировать контроль версий API (то есть как клиент должен указывать нужную ему версию API). В случае воплощающих REST API чаще всего указывается номер версии в URL или в HTTP-заголовке Accept.
RESTful
API, спроектированный в соответствии с принципами REST, называют воплощающим REST (RESTful).
requests
websocket
WebSocket — это наиболее распространенное решение для передачи асинхронных обновлений от сервера к клиенту. Соединение по WebSocket инициируется клиентом. Оно является двунаправленным и постоянным. По этому постоянному соединению сервер может отправлять обновления клиенту.
WebSocket является двунаправленным, поэтому с технической точки зрения нам ничего не мешает применять его не только для отправки, но и для получения сообщений.
Flask
aiohttp
Django
API
Аутентификация
Токены JWT
Swagger
FastAPI
GraphQL
Написать raw запрос главной Яндекса
GET / HTTP/1.1 Host: ya.ru
Как клиенту понять, удался запрос или нет?
Проверить статус ответа. Ответы разделены по старшему разряду. Имеем пять групп со следующей семантикой:
1xx: используется крайне редко. В этой группе только один статус 100 Continue. 2xx: запрос прошел успешно (данные получены или созданы) 3xx: перенаправление на другой ресурс 4xx: ошибка по вине пользователя (нет такой страницы, нет прав на доступ) 5xx: ошибка по вине сервера (ошибка в коде, сети, конфигурации) Что нужно отправить браузеру, чтобы перенаправить на другую страницу?
Минимальный ответ должен иметь статус 301 или 302. Заголовок Location указывает адрес ресурса, на который следует перейти.
В теле ответа можно разместить HTML со ссылкой на новый ресурс. Тогда пользователи старых браузеров смогут перейти вручную.
Как управлять кешированием в HTTP?
Существуют несколько способов кешировать данные на уровне протокола.
Заголовки Cache и Cache-Control регулируют сразу несколько критериев кеша: время жизни, политику обновления, поведение прокси-сервера, тип данных (публичные, приватные). Заголовки Last-Modified и If-Modified-Since задают кеширование в зависимости от даты обновления документа. Заголовок Etag кеширует документ по его уникальному хешу. Как кэшируются файлы на уровне протокола?
Когда Nginx отдает статичный файл, он добавляет заголовок Etag – MD5-хеш файла. Клиент запоминает этот хеш. В следующий раз при запросе файла клиент посылает хеш. Сервер проверяет хеш клиента для этого файла. Если хеш не совпадает (файл обновили), сервер отвечает с кодом 200 и выгружает актуальный файл с новым хешем. Если хеши равны, сервер отвечает с кодом 304 Not Modified с пустым телом. В этом случае браузер подставляет локальную копию файла.
nginx vs Apache
Всё просто — нужно использовать nginx всегда, когда есть возможность. Apache — для шаред хостингов, но даже на шаред хостингах часто фронтендом стоит nginx (без возможности настройки), а уже за ним — настраиваемый Apache и настраиваемый PHP.
Архитектура: Apache создает отдельный поток для каждого соединения, nginx работает по асинхронной модели. Статический контент: nginx примерно в 2 раза быстрее Apache. Динамический контент: ничья. Функционал: Apache разработан только как веб-сервер, nginx может работать и как веб-сервер, так и как прокси-сервер. Настройка: nginx проще в настройке.
CDN
CDN (content delivery network) — сеть географически распределенных серверов, которая используется для максимально быстрой доставки статического содержимого. Серверы CDN кэшируют такие статические файлы, как изображения, видео, CSS, JavaScript и т. д.
RPC
Веб-сервисы — просто новейшее воплощение длинной череды технологий выполнения запросов к API по сети, многие из которых наделали в свое время немало шума, но имели серьезные проблемы. Enterprise JavaBeans (EJB) и удаленные вызовы методов языка Java (remote method invocation, RMI) ограничены Java. Распределенная компонентная объектная модель (distributed component object model, DCOM) ограничивается платформами компании Microsoft. Общая архитектура брокера объектных запросов (common object request broker architecture, CORBA) излишне сложна и не обеспечивает прямой или обратной совместимости.
Все они основаны на понятии удаленного вызова процедуры (remote procedure call, RPC), появившемся еще в 1970-х годах. Основная идея модели RPC состоит в том, что выполнение запроса к удаленному сетевому сервису должно выглядетьтак же, как и вызов функции или метода на обычном языке программирования,в пределах одного процесса (эта абстракция называется независимостью от расположения (location transparency)). Хотя на первый взгляд RPC представляется весьма удобным, у этого подхода есть фундаментальные недостатки. Сетевой запрос сильно отличается от локального вызова функции.
Локальному вызову функции присуща предсказуемость, он или завершается успешно, или нет, в зависимости только от контролируемых вами параметров. Сетевой запрос непредсказуем: запрос или ответ на него способен потеряться вследствие сетевого сбоя, удаленная машина может работать медленно или быть недоступной, и подобные проблемы — вне вашего контроля. Проблемы сети встречаются очень часто, их нужно стараться предугадывать, например, путем повтора отправки неудавшихся запросов.
Локальный вызов функции или возвращает результат, или генерирует исключение, или вообще ничего не возвращает (поскольку, например, попадает в бесконечный цикл либо вследствие фатального сбоя процесса). У сетевого запроса есть еще один вероятный исход: может произойти возврат, но без результата вследствие превышения времени ожидания. В этом случае вы просто не будете знать, что произошло: если не получили ответ от удаленного сервиса, то никак не можете знать, был ли доставлен и выполнен запрос.
Повторяя отправку неудавшегося сетевого запроса, вы должны учитывать возможность того, что ваши запросы на самом деле доставляются и выполняются, а теряются лишь ответы. В этом случае повтор отправки приведет к повторному выполнению действия, если только вы не встроите в протокол механизм дедупликации (идемпотентности). У локальных вызовов функций этой проблемы нет.
Каждый вызов локальной функции занимает примерно одно и то же время. Сетевые запросы выполняются намного медленнее вызовов функций, причем задержка варьируется гораздо сильнее: в удачное время он может выполняться менее чем за миллисекунду, но если сеть или удаленный сервис перегружены,то выполнение того же самого запроса может занять многие секунды.
При вызове локальной функции ей можно успешно передать ссылки (указатели) на объекты в локальной памяти. При выполнении сетевого запроса все эти параметры приходится кодировать в байтовые последовательности, которые можно отправить по сети. В случае параметров, относящихся к простым типам данных (например, строк или чисел), никаких проблем нет, но для больших объектов все резко усложняется.
Клиент и сервис могут быть реализованы на разных языках программирования, так что фреймворку RPC придется преобразовывать типы данных одного языка программирования в типы данных другого. Это может закончиться плохо, ведь типы далеко не всех языков одинаковы — вспомним хотя бы проблемы JavaScript с числами, превышающими 2^53. В случае одного процесса, написанного на одном языке программирования, эта проблема отсутствует.
Все приведенные факторы означают одно: нет смысла пытаться сделать удаленный сервис похожим на локальный объект языка программирования, поскольку это нечто принципиально иное. Подход REST, в частности, тем и привлекателен, что не пытается скрывать сетевую природу протокола (хотя это не удерживает некоторых от создания RPC-библиотек на основе REST).
Пользовательские протоколы RPC с двоичным форматом кодирования могут быть более производительными, чем универсальные, такие как JSON поверх REST. Однако у воплощающих REST API есть другие важные преимущества: они хорошо подходят для экспериментирования и отладки (к ним можно выполнять запросы просто через браузер или утилиту командной строки curl, без какой-либо генерации кода или установки программного обеспечения), их поддерживают все основные языки программирования и платформы. Кроме того, для них существует обширная экосистема вспомогательных утилит (серверов, кэшей, балансировщиков нагрузки, прокси, брандмауэров, утилит мониторинга, отладки,утилит для тестирования и др.).
Благодаря вышеперечисленному REST остаётся господствующим стилем общедоступных API
gRPC
gRPC — реализация RPC, использующая Protocol Buffers
11. Архитектура программного обеспечения
«Хорошая архитектура отвечает потребностям пользователей, разработчиков и владельцев не только сейчас, но и продолжит отвечать им в будущем».
Роберт Мартин, «Чистая архитектура».
Что такое архитектура
Во вопросу точного значения термина «Архитектура программного обеспечения» было сломано много копий, но точного (и притом общепринятого) ответа пока не найдено. Что касается моего личного мнения, то, на мой взгляд, ПО, построенное по принципам «хорошей» архитектуры, должно удовлетворять всего двум требованиям:
- выполнять требования ТЗ в настоящем;
- спокойно и легко реагировать на изменения ТЗ в будущем.
Для этого, в свою очередь, ПО должно быть:
- правильно разрезано на кусочки (правильно фрагментировано на зоны ответственности, файлы, классы и микросервисы) — целостные, со сфокусированной зоной ответственности, понятные и относительно простые;
- правильно соединено (должны быть определены общие структуры данных, интерфейсы и механизмы передачи данных между фрагментами).
А вот три архитектурных требования из книги Мартина Клеппмана "Высоконагруженные приложения" (или как её часто называют в русскоязычном сегменте интернета, "Книги с кабанчиком"):
- надежность - система должна продолжать работать корректно (осуществлять нужные функции на требуемом уровне производительности) даже при неблагоприятных обстоятельствах (в случае аппаратных или программных сбоев либо ошибок пользователя);
- масштабируемость - должны быть предусмотрены разумные способы решения возникающих при росте (в смысле объемов данных, трафика или сложности) системы проблем;
- удобство сопровождения - необходимо обеспечить возможность эффективной работы с системой множеству различных людей (разработчикам и обслуживающему персоналу, занимающимся как поддержкой текущего функционирования, так и адаптацией системы к новым сценариям применения).
Чистая архитектура
Обычно принципы действия работы чистой архитектуры принято разбирать, основываясь на симпатичной круговой диаграмме (ранее использовался даже термин Onion Architecture, ныне подзабытый), продвигаясь от центра к внешним границам и разъясняя трансформации, происходящие по пути. Круговые диаграммы выглядят эстетично и вселяют какую-то особую надежду на своё непорочное совершенство и непоколебимую устойчивость. На части иллюстраций многослойный кружочек еще и разрезают, выворачивая его сначала в полукружие, а потом и в линейную нарезку.
Давайте попробуем всё немного упростить и обойтись вовсе без графических иллюстраций.
Для этого рассмотрим сначала обратный пример — «грязную» архитектуру.
Итак, мы хотим создать совершенно новую программу. Не будем усложнять пример, мы хотим написать не картографический сервис, конкурент Google Maps и Яндекс Карт, а несложную программу-игрушку «Крестики-Нолики» на C# с простейшим CLI (Command Line Interface), когда ввод хода пользователя и вывод результирующего игрового поля происходит не при помощи графического интерфейса, а с помощью скромного bash. Коротенькая программа написана, хорошо работает и радует своего владельца.
Программист, который разработал «Крестики-Нолики», был человеком разносторонним и решил переделать интерфейс программы с CLI на WPF. Переделка заняла не очень много времени и очень скоро новая программа в Windows-стиле радовала своего создателя. Еще через некоторое время разработчик при помощи Blazor сделал себе такую же игрушку, но с web-интерфейсом, чтобы можно было скоротать время при помощи браузера.
Спустя некоторое время программист обнаружил небольшую ошибку в коде программы. При некоторых, нечастых комбинациях игрового поля игра вела себя некорректно и выдавала неверный результат. К счастью, ошибка была простой и её исправление во всех
