PyWay – гуру Python 🐉
883 subscribers
22 photos
1 video
2 files
153 links
Ваш путь в глубины Python. Узнай все секреты и фишки у нас! Если хотите сотрудничать – пишите @account1242 (условия @pyway_ads)
Download Telegram
@dataclass

Классы данных появились в Python с версии 3.7. Изначально они описаны в PEP-0557.
Главным образом, dataclass предназначены для структурированного хранения данных. Они облегчают работу программиста путем автоматической генерации рутинного кода, а именно инициализации, сравнения, представления repr и прочего.

Короче, если обычно мы пишем так:

class Computer:
def __init__(self, cores, ram=2048):
self.cores = cores
self.ram = ram

def __repr__(self):
return f'Computer({self.cores}, {self.ram})'

# прочие методы, eq


Это можно сократить до:

from dataclasses import dataclass

@dataclass
class Computer:
cores: int
ram: int = 2048

print(Computer(2)) # Computer(cores=2, ram=2048)

Как видите, очень лаконично и читабельно. Здесь типы (аннотации) – обязательная вещь. Без них поле не будет считаться атрибутом экземпляра класса, то есть для него не будет сгенерирован код!

@dataclass
class Number:
value: int # поле, сгенерируется код
foo = 10 # просто переменная уровня класса, будет проигнорирована

print(Number(10, foo=20)) # ошибка!


Существует еще более короткий способ определения класса данных:

from dataclasses import make_dataclass
Computer = make_dataclass('Computer', ('ip', 'cores', 'ram'))


Как видите, dataclass очень похожи на namedtuple из прошлых постов, однако они НЕ являются кортежами и вообще внутренне устроены иначе, словно обычные класс, в которых кто-то за нас определил init и прочие полезные методы. По умолчанию dataclass является изменяемым типом. То есть его методы могут менять значения полей.

Значения по умолчанию позволяют опускать поля при инициализации, но после первого поля с дефолтным значением все остальные поля также обязаны иметь свои дефолтные значения!

Напоследок, вот так легко можно превратить dataclass в dict, например, для последующего сохранения в JSON:

from dataclasses import asdict
asdict(Computer(cores=8, ram=2**15)) # {'cores': 8, 'ram': 32768}


Официальная документация dataclass на английском.

Темы далее:

1. Настройка dataclasses
2. Наследование
3. Пост-инициализация
4. Хитрости с полями list, dict
Настройка dataclass

В предыдущем посте мы использовали dataclass без параметров, однако у него есть возможности включить или выключить разные генерации методов:

@dataclass(init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False)
class C:
...

Краткое объяснение каждого параметра (в скобках – значения по умолчанию):

1. init (вкл) – генерация метода инициализации. Игнорируется, если у класса уже есть свой init.
2. repr (вкл) – генерации метода repr для класса. Дает удобное представление класса при печати его в консоль или в логи.
3. eq (вкл) – генерировать ли метод равенства ==? Сравниваются все поля по очереди (похоже на tuple).
4. order (выкл) – добавить ли методы сравнения (>, ≥, ≤, <)? Принцип сравнения такой же, как у tuple – поля сравниваются по порядку сверху вниз.
5. frozen (выкл) – заморозка полей. При попытке присвоения к полям класса возникнет исключение. Таким образом класс становится только для чтения (чем-то похож на namedtuple).
6. unsafe_hash (выкл) – отвечает за генерацию метода hash. Почему unsafe (то есть небезопасный)? Потому что ваш класс может быть мутабельным (изменяемым), поэтому вы сами берете на себя ответственность за соответствия содержимого класса и его хэша. Ситуация, когда сначала от экземпляра берут хэш, чтобы, например, сохранить его во множестве, а потом поля класса меняются, приводит к наличию двух одинаковых элементов во множестве. Это противоречит его смыслу, но возможно. У меня был пост об этом.
Когда у вас frozen=True и eq=True, то класс считается уже неизменяемым, и к нему автоматически генерируется hash-функция, поэтому не требуется дополнительно ставить unsafe_hash=True.

По поводу сравнения на больше-меньше, чтобы понимали, даю пример:

from dataclasses import dataclass

@dataclass(order=True)
class Player:
name: str
score: int

p1 = Player('Maksim', 10)
p2 = Player('Ivan', 30)
p3 = Player('Ivan', 40)

print(f'{p1} > {p2} = {p1 > p2}') # True: Maksim > Ivan
print(f'{p2} > {p3} = {p2 > p3}') # False: Ivan == Ivan, но 30 < 40!


Сначала сравниваются имена (как строки). Строки сравниваются посимвольно: можете себе представить, что ищите слово в бумажном словаре, так вот там слово Maksim будет стоять всегда после слова Ivan. Зная, что Maksim > Ivan, получается что p1 > p2. Сравнение даже не смотрит на поле score (счет).

Во втором случае, оба игрока имеют одинаковые имена Ivan, и метод сравнения переходит уже к полю score. Следовательно, p2 > p3 == False, потому что p2.score < p3.score.

Если вам требуется какая-то особая логика сравнения, то придется вручную реализовать магические методы сравнений.

В сравнениях и равенствах участвуют только те поля, у которых вы пометили тип через аннотации (или задали через функцию field).
Функция field позволяет настраивать каждое поле класса индивидуально. Это тема для отдельного поста.
from dataclasses import dataclass, field

Функция field позволяет настроить каждое поле dataclass индивидуально. Можно создавать поля и без field, задав просто тип и при необходимости значение по умолчанию. Однако, есть случаи, когда без field не обойтись. Давайте рассмотрим по очереди все его настройки. Итак, полный прототип функции выглядит так:

field(*, default=MISSING, default_factory=MISSING, repr=True, hash=None, init=True, compare=True, metadata=None)

Во-первых, звездочка в начале списка параметров говорит о том, что позиционные аргументы не допускаются, каждый аргумент должен быть указан всегда по имени.

1) default – значение поля по умолчанию. То же самое, что идет после равно, если мы не используем field.

@dataclass
class Foo:
x: int = field(default=10) # x: int = 10


Этот способ указания значения по умолчанию подходит для неизменяемых типов данных: чисел (int, float, ...), строк (str), кортежей (tuple).

Если значение по умолчанию не указано, то вы должны задавать это поле как параметр в конструкторе (или во время пост-инициализации, о ней будет в другом посте).

2) default_factory – это второй способ задать значение по умолчанию. Здесь вы предоставляете фабрику – функцию без аргументов, которая будет вызываться КАЖДЫЙ раз при создании экземпляра класса, а результат ее работы будет записан в данное поле.
Такой способ обязателен для изменяемых (мутабельных) типов данных, таких как списки (list), словари (dict), множества (set) и пользовательские классы с изменяемыми полями.

Пример. Пусть одно из полей – список, тогда нельзя просто взять и написать для него = []. В последних версиях Python здесь будет исключение уже на этапе определения класса! Иначе бы все экземпляры Class разделяли бы один и тот же объект списка в поле students:

@dataclass
class Class:
number: int
students: list = [] # Ошибка!


Здесь следует задать field с указанием default_factory.

students: list = field(default_factory=lambda: [])
# или еще короче и правильнее вот так:
students: list = field(default_factory=list)


Функция list без аргументов каждый раз возвращает НОВЫЙ пустой список. Аналогично можно писать default_factory=set или default_factory=dict или даже default_factory=MyClass, если у класса MyClass есть конструктор без обязательных аргументов.

3) repr – этот параметр позволяет исключить поле из строкового представления класса. Например, если поле – пароль, вы можете пожелать, чтобы оно не отображалось в логах:

@dataclass
class User:
name: str
password: str = field(repr=False)

admin = User('admin', '1234')
print(admin) # User(name='admin')


4) init – включать или нет поле в код инициализации init. Возможно, вы хотите вычислить это поле во время пост-инициализации и не требовать его в конструкторе класса, тогда init=False – ваш выбор.

5) compare – включать ли поле в методы сравнения (равно, больше, меньше)? Если конкретное поле не должно влиять на результат сравнения или равенства двух экземпляров класса, просто выключите его через compare=False:

@dataclass(order=True)
class User:
name: str = field(compare=False)
age: int

ken = User('Ken', 20)
linda = User('Linda', 18)
print(ken > linda) # True


В примере сравнивается только age. Попробуйте убрать compare=False и увидите, что результат сравнения изменится.

6) hash – может быть True, False или None. Определяет, участвует ли данное поле в хэш-функции. Если None (это так по умолчанию), то решение принимается на базе compare: если поле участвует в сравнениях, то и учитывается при вычислении хэша от класса. Если True – то всегда участвует в хэш-функции, если False – то всегда исключается.

7) metadata – словарь для метаданных, иначе говоря для каких-то дополнительных свойств этого поля, которые разработчик придумывает сам. Напрямую это обычно не используется, а встречается, как правило, в коде сторонних библиотек. Например, туда можно прикрепить название колонки в SQL-таблице или ключ для сериализации.
😷 Друзья, прошу прощения, что не писал ничего вот уже месяц. Злобные вирусы нас никак не отпускают,
а оплачиваемых больничных у вольных разработчиков не бывает. Сегодня будет коротенький, но полезный постик.
🤖 Любителям отлаживать принтами (к коим и я порой отношусь) очень понравится.

На заре мы писали так, когда хотели распечатать значение переменной рядом с ее именем:

my_var, foo = 10, "abc"
print('Debug my_var =', my_var, 'and foo = "' + foo + '"')

Потом нам показали f-строки, и мы стали писать так:

print(f'Debug my_var = {my_var} and foo = "{foo}"')

И все бы ничего, если бы не приходилось каждый раз дублировать название переменной до знака "равно" и после в фигурных скобках!
К счастью в версии Python 3.8 нас ждал королевский подарок, а именно возможность печатать за один раз и название, и значение переменной, просто добавив в конце знак "равно". Вот так:

print(f'Debug {my_var=} and {foo=}')
# Напечатает: Debug my_var=10 and foo='abc'

Если кавычки для строки не нужны, можно указать спецификатор !s:

print(f'{foo=!s}') # foo=abc

Удивительно, но пробелы, которые вы оставите в строке формата, также перенесутся на результат:

print(f'{foo = }') # foo = 'abc'

Причем это может быть необязательно переменная, а целое выражение, причем левая часть будет ровно в той форме, что и в форматной строке, ну не прекрасно ли это?

print(f'{len([1, 2, 3]) = } and {10 + 5 * 3 = }')
# Напечатает: len([1, 2, 3]) = 3 and 10 + 5 * 3 = 25

Также после равно могут идти любые прежние спецификаторы формата, например, указание точности числа с запятой:

print(f'{my_var = :.2f}')
# my_var = 10.00

Или вот такое оформление:

print(f'{foo = :-^20}')
# foo = --------abc---------


Теперь ваша отладка принтами (или логгирование) станет легче и быстрее.
Давайте уже закончим серию постов про dataclass.
Сегодня рассмотрим возможность наследования dataclass.
Сходу рассмотрим пример:

from dataclasses import dataclass

@dataclass
class Parent:
x: int

@dataclass
class Child(Parent):
y: str

c = Child(20, "String")

Как видите, наследование достаточно прямолинейно. Однако, стоит отменить, что в конструкторе Child сначала идут родительские атрибуты (x из Parent), и только потом атрибуты (y из Child).

Декоратор @dataclass можно указать только у класса-наследника, если вы не планируете использовать родительские классы в качестве dataclass. Декоратор пробегается по всей цепочке классов (MRO) в обратном порядке (от object к Child), то есть сначала Parent, потом Child, и собирает все подходящие поля (обозначенные аннотацией типа или как field) в упорядоченный словарь. Сгенерированные методы будут опираться на набор полей именно из этого словаря.

Вообще, не запрещено в дочерних классах повторять поля из родительских, даже меняя их тип и значение по умолчанию. Например, допустимо в Child добавить x: str = "344". Тогда x приобретет аннотацию строки в классе Child. Однако, я бы не рекомендовал этого делать без особой надобности, так как это может ввести в заблуждение при чтении кода.

Частая проблема, которая встречается при наследовании dataclass: если в Parent есть поля со значением по умолчанию, то и во всех его наследниках dataclass ВСЕ поля должны быть со значениями по умолчанию. Дело как раз в порядке следования аргументов в инициализаторе. Родительские поля идут сначала, и за ними просто не может быть обязательных полей, синтаксис такого не позволяет:

def __init__(self, x = 5, y, z): # нельзя

Возможное решение проблемы заключается в том, чтобы создать вспомогательные классы, разделив поля на: а) просто поля без умолчания и б) поля с ним. А потом в Child и Parent наследоваться от нескольких классов сразу, собрав нужные поля и их однофамильцев со значениями по умолчанию:

@dataclass
class _ParentBase:
name: str
age: int

@dataclass
class _ParentDefaultsBase:
smart: bool = False

@dataclass
class _ChildBase(_ParentBase):
school: str

@dataclass
class _ChildDefaultsBase(_ParentDefaultsBase):
smart: bool = True

@dataclass
class Parent(_ParentDefaultsBase, _ParentBase):
pass

@dataclass
class Child(Parent, _ChildDefaultsBase, _ChildBase):
pass

from inspect import signature

print(signature(Parent))
# (name: str, age: int, smart: bool = False) -> None
print(signature(Child))
# (name: str, age: int, school: str, smart: bool = False) -> None


Как раз таки это именно тот случай, когда повторное определение полей с теми же именами имеет смысл.
Кстати, функция signature позволяет посмотреть сигнатуру метода и установить, все ли поля и их дефолтные значения на месте.

В итоге решение получилось немного громоздко. Стоит ли игра свеч, решать вам.
Пост-инициализация dataclass

Так как метод __init__ в dataclass генерируется декоратором автоматически, а иметь второй такой метод в классе нельзя, то в случае, если разработчик хочет совершить дополнительные действия при инициализации класса, есть решение. После основной инициализации будет вызван магический метод пост-инициализации __post_init__ ("пост" значит "после"). В нем можно вычислить значения одних полей на основе других. Например, создать описание продукта из его названия и цены:

from dataclasses import dataclass, field, InitVar

@dataclass
class Product:
name: str
price: float
desc: str = field(init=False) # будем вычислять

def __post_init__(self):
self.desc = f"{self.name}: ${self.price:.2f}"

print(Product('Масло', 3.5))
# Product(name='Масло', price=3.5, desc='Масло: $3.50')


⚠️ Если в настройках класса вы выключили init=False, то __post_init__ не будет вызываться.

Вообще __post_init__ может принимать параметры. Это параметры только для инициализации – особые поля dataclass, помеченные аннотацией InitVar. Если таковые имеются, то они будут переданы как параметры в __post_init__ из конструктора при создании экземпляра класса. Больше они никак не используются и не будут храниться в экземпляре.
По сути они нужны для настройки процесса пост-инициализации. Допустим в прошлом примере мы введем флаг, который будет отключать генерацию описания (desc):

@dataclass
class Product:
name: str
price: float
desc: str = field(init=False, default='')
generate_desc: InitVar[bool] = True # по умолчанию описание генерируется

def __post_init__(self, generate_desc):
if generate_desc:
self.desc = f"{self.name}: ${self.price:.2f}"

print(Product('Масло', 3.5))
# Product(name='Масло', price=3.5, desc='Масло: $3.50')

print(Product('Масло', 3.5, generate_desc=False))
# Product(name='Масло', price=3.5, desc='')


Параметр generate_desc можно передать при создании экземпляра класса, но потом в классе его уже нет, он используется только в __post_init__.

P.S. Все вырученные средства от рекламы в этом канале идут на благотворительность, а именно в приют 🐈 "Планета кошек" в Нижнем Новгороде.
Зачем нужен __new__?

Скорее всего, вы знаете и используете метод __init__ в классах для инициализации полей. Однако, при создании класса ему предшествует вызов метода __new__.

Специальный метод __new__ должен вернуть экземпляр класса (создав его при необходимости), который затем будет передан первым аргументом в соотвествующий __init__. Поскольку сам __init__ получает уже готовый экземпляр и всего лишь инициализирует его поля, ничего не возвращая, он не может повлиять на тип объекта или его источник. Конечно, непосредственным созданием экземпляра занимается сам Python в глубинах object.__new__, но кастомный __new__ обычно используется в следующих сценариях:

1. Подмена типа. Пользователь создает объект одного типа, а по факту получает объект другого типа, например, более специализированный.
2. Когда нужно не создавать новый объект, а отдать уже созданный ранее. Например, синглтон создается однажды, сохраняется и используется на протяжении всего времени работы программы.
3. Замена __init__.

ℹ️ __new__ – это всегда классовый метод, но к нему НЕ нужен декоратор classmethod.

Давайте рассмотрим тривиальный пример:

class Bulka:
def __init__(self, a, b):
print(f'Bulka init {a=}, {b=}')

def __new__(cls, *args, **kwargs):
print(f'{cls=}, {args=}, {kwargs=}')
# cls=<class '__main__.Bulka'>, args=(5,), kwargs={'b': 7}
return super().__new__(cls)

Bulka(5, b=7)


Из вызова Bulka(5, b=7), мы попадаем в Bulka.__new__, где cls равен Bulka, а в args и kwargs хранятся все изначально переданные аргументы. Мы просто их распечатываем на экране и возвращаемся к поведению по умолчанию, а именно вызову return super().__new__(cls). Обратите внимание, что тут мы передаем только cls, опуская все прочие параметры (версия 3.3+) Но вы должны в любом случае оставить *args, **kwargs в заголовке __new__, даже если их не используете явно внутри тела этого метода. Вместо *args, **kwargs можно указать явный список параметров с их именами, аннотациями и дефолтными значениями, если вам так удобно.

Также, можете не пытаться изменить содержимое args и kwargs. args – это вообще кортеж, он неизменяем. А изменения в kwargs не отразятся в __init__. Однако разрешено вообще не использовать __init__, а задать все поля нового экземпляра прям в __new__.

Вот пример класса вообще без __init__, на голом __new__.

class Coffee:
def __new__(cls, volume = '300ml', milk: bool = False):
obj = super().__new__(cls)
obj.volume = volume
obj.milk = milk
return obj

c = Coffee()
print(f'{c.volume=}, {c.milk=}') # c.volume='300ml', c.milk=False


Фактически __init__ экономит вам всего лишь две строки. Пример от разработчиков Python – datetime.

А вот пример реализации шаблона "синглтон".

class Singleton(object):
_instance = None # хранит единственный экземпляр

def __new__(cls, *args, **kwargs):
if not cls._instance:
# первый вызов – создаем реально объект
cls._instance = object.__new__(cls)
return cls._instance

print(Singleton() is Singleton()) # True


Вот еще один пример, в нем мы подменяем тип на выходе.

class Maslo:
def __init__(self):
print(f'Maslo')

class Bulka:
def __init__(self, a, b):
print(f'Bulka init {a=}, {b=}')

def __new__(cls, *args, **kwargs):
if not args and not kwargs:
# пусть если без аргументов, то масло!
return Maslo()
else:
return super().__new__(cls)

print(Bulka(5, b=7)) # Bulka init a=5, b=7
print(Bulka()) # Maslo


Хотите изучить остальные эталонные примеры использования __new__? Просто зайдите в папку lib в вашем установленном Python и выполните команду: grep -l "def __new__(" *.py Их найдется целая куча в файлах:

_py_abc.py, _pydecimal.py, _pyio.py, _threading_local.py, abc.py, codecs.py, datetime.py, enum.py, fractions.py, functools.py, pathlib.py, pstats.py, sre_constants.py, ssl.py, turtle.py, typing.py, weakref.py

Еще __new__ используется в метаклассах, но это отдельная история.
🗑️ Очистка списка

Есть одна ловушка, и, вероятно, кто-то с ней еще не знаком. Если к переменной со списком присвоить [], то, конечно же, в ней теперь окажется пустой список, но это будет уже совсем другой объект по другому адресу, а старый список так и останется со своими элементами. Обычно это никак не влияет на работу программы, но есть ситуации, когда, к примеру, список передается аргументом в функцию, которая может захотеть удалить из исходного списка все элементы:

def scruffy(arr: list):
arr = []
print(f'Готово! {arr=}') # Готово! arr=[]

x = [1, 2, 3]
scruffy(x)
print(x) # по-прежнему, [1, 2, 3]


У Скраффи не получилось сделать свою работу! Как быть? Есть два основных приема:

Первый: метод clear()

>>> x = y = [1, 2, 3]
>>> x.clear()
>>> x is y
True


Второй вариант, это использование слайсов (срезов): x[:] = []

>>> x = y = [1, 2, 3]
>>> x[:] = []
>>> x is y
True


В обоих случаях x продолжает ссылаться на тот же объект списка (мы сохранили ссылку не него в y), но из него удалены все элементы.

Вообще, срезы – очень мощная система работы со списками. Когда-то, на заре этого канала, я уже писал о них. Настало время напомнить некоторые их фишки в следующем посте.
Срезы (или слайсы – от. slice) позволяют получить гибкий доступ к частям списков, кортежей, строк и других последовательных типов. Причем, как на чтение, так и на запись и удаление. Ниже я приведу несколько примеров использования срезов.

😋 Помните! Индексирование в Python идет с 0. Первый элемент имеет индекс 0.

Общая нотация среза выглядит так: arr[start:stop:step], где start, stop и step могут быть как числами, так и переменными или выражениями, но кроме того могут быть и вовсе пустым местом. start – это индекс элемента, с которого начинается выборка включительно, stop – индекс, на котором выборка останавливается (не включительно), step – это шаг, по умолчанию он равен 1. Все эти параметры могут быть и отрицательными, что придает им особый смысл.

🤭 Обращение по срезу не бросает исключений при выходе за границы исходной коллекции! Просто полученное множество может оказаться пустым.

Пусть есть исходный список:
arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

Все элементы, начиная с индекса 1:
arr[1:] == [2, 3, 4, 5, 6, 7, 8, 9, 10]
arr[5:] == [6, 7, 8, 9, 10] # или с 5 индекса
arr[100:] == [] # за пределами просто пустое множество, нет ошибки

Отрицательные значения заставляют отсчитывать элементы с конца коллекции.

Четыре последних элемента:
arr[-4:] == [7, 8, 9, 10]

ℹ️ Если вам нужен конкретный элемент, считая с конца:
arr[-1] == 10 # последний
arr[-2] == 9 # предпоследний
arr[-10] == 1 # первый – самый дальний с конца
arr[-100] # IndexError! выскочили за пределы

Вернемся к срезам. Получить первые три элемента:
arr[:3] == [1, 2, 3]

Первые элементы, не считая 6 последних:
arr[:-6] == [1, 2, 3, 4]

Диапазон. Тут индекс 4 включен (элемент равный 5), в индекс, равный 7 (это элемент со значением 8 ) уже нет.
arr[4:7] == [5, 6, 7]
arr[100:200] == [] # за пределами просто пустое множество, нет ошибки

Диапазон с 5-го элемента с конца до 2-го элемента с конца.
arr[-5:-2] == [6, 7, 8]
arr[-2:-5] == [] # порядок важен, потому что 2-ой с конца идет позже 5-го с конца!

С 8-го с конца до 4-го с начала:
arr[-8:4] == [3, 4]

С индекса 2 с начала до 3-го элемента с конца:
arr[2:-3] == [3, 4, 5, 6, 7]

Работа с шагом.

Каждый второй элемент, иными словами индексы 0, 2, 4 и так далее:
arr[::2] == [1, 3, 5, 7, 9]

Каждый третий элемент:
arr[::3] == [1, 4, 7, 10]

Каждый второй элемент, со сдвигом в 1 (индексы 1, 3, 5, ...):
arr[1::2] == [2, 4, 6, 8, 10]

Элементы c индекса 1 до 8 с шагом 4:
arr[1:8:4] == [2, 6]

Шаг не может быть нулем, иначе какой же это шаг? Это стояние на месте:
arr[::0] # ValueError

Отрицательный шаг всегда разворачивает подмножество. Список задом наперед:
arr[::-1] == [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]

Каждый второй элемент с конца (индексы 9, 7, 5 и так далее):
arr[::-2] == [10, 8, 6, 4, 2]

С отрицательным шагом начальный индекс должен быть позже конечного:
arr[-2:-5:-1] == [9, 8, 7]
arr[5:2:-1] == [6, 5, 4]

Не выйдет досчитать с 2 до 5 в отрицательную сторону. После индекса 2 следует 1, таким образом, до 5 мы никогда не дойдем.
arr[2:5:-1] == []
arr[-5:-2:-1] == []


⚠️ Пропуски между двоеточиями подразумеваются как None, а не как 0:
arr[::-2] == arr[None:None:-2] == [10, 8, 6, 4, 2]
arr[0::-2] == [1] # читай: начни с 0 индекса и шагай до упора влево по 2.

Условие start=None можно читать как "с самого края", а stop=None можно читать как "до упора".
С какого до какого края – это зависит от знака step, как вы понимаете.

Подобным образом можно работать и со строками, вырезая из них подстроки.
"hello world"[1:-3] == 'ello wo'
"hello world"[-2::-1] == 'lrow olleh'

Сперва это жонглирование индексами может показаться трудным для понимания, но советую в интерпретаторе просто взять список и попробовать разные комбинации индексов самостоятельно.

В следующем посте расскажу про запись и удалением по срезам.
✂️ Работа со срезами на запись и удаление

Во-первых, самое главное. При присвоении по срезу к другой переменной создается поверхностная копия данных!
Поверхностная (shallow) копия – это копирование ссылок на нужные элементы, при этом содержимое элементов не копируется.

arr = [1, 2, 3, 4, 5, 6, 7, 8]
bar = arr[1:6]
bar[3] = 8888
print(bar) # [2, 3, 4, 8888, 6] - новый изменен
print(arr) # [1, 2, 3, 4, 5, 6, 7, 8] – старый без изменений!

Лайфхак. Копирование списка: arr_copy = arr[:]

⚠️ Но! При манипуляциях над самой коллекцией – копии не создается, данные меняются в исходном объекте! Поэтому перед каждым примером подразумеваем, что arr заново равен исходному [1, 2, 3, 4, 5, 6, 7, 8].

Замена 2-го и 3-го элементов на другие:
arr[2:4] = [777, 555]
print(arr) # [1, 2, 777, 555, 5, 6, 7, 8]

Замена может содержать любое количество элементов, как большее, так и меньшее, или равное размеру заменяемого диапазона. Размер списка может при этом измениться.

Заменим последние два элемента на 4 новые:
arr[-2:] = [11, 12, 13, 14]
# arr == [1, 2, 3, 4, 5, 6, 11, 12, 13, 14]

Вставим в середину, а именно с 4-го индекса несколько новых чисел:
arr[4:4] = [111, 222, 333]
# arr == [1, 2, 3, 4, 111, 222, 333, 5, 6, 7, 8]

Присвоить пустой список равносильно вырезанию. Вырежем первые 5 элементов:
arr[:5] = []
# arr == [6, 7, 8]

Кстати, тот же эффект достигается выражением:
del arr[:5]

Все становится немного сложнее, если использовать шаг. Тогда количество заменяемых элементов должно быть в точности равно количеству замещающих.

Заменить каждый второй элемент:
arr[::2] = [10, 20, 30, 40]
# arr = [10, 2, 20, 4, 30, 6, 40, 8]

arr[::2] = [10, 20, 30] # ValueError (не хватает чисел в правой части)
arr[::2] = [10, 20, 30, 40, 50] # ValueError (слишком много)

Можно также и с конца:
arr[::-2] = [10, 20, 30, 40]
# arr == [1, 40, 3, 30, 5, 20, 7, 10]

С правой стороны присваивания необязательно должен быть список. Может быть и генератор.
Например, обратим каждый третий элемент списка генератором:
arr[::3] = map(lambda x: 1 / x, arr[::3])
# arr == [1.0, 2, 3, 0.25, 5, 6, 0.14285714285714285, 8]

Удаление элементов с 1 по 7 (не включая 7), через 2:
del arr[1:7:2]
arr == [1, 3, 5, 7, 8]

Так как присвоение и удаление по срезу изменяют содержимое объекта, то такие операции не применимы к иммутабельным типам данных, таким как кортежи, строки, байты. Обычно их цели: списки и bytearray.
🤓 Задачка на смекалку

itertools.repeat – бесконечный генератор повторяющихся значений.

Что будет при выполнении этого кода?
Пояснение про копирование 🤏🏻

В прошлом посте вот этот пример вызвал вопросы:

arr = [1, 2, 3, 4, 5, 6, 7, 8]
bar = arr[1:6]
bar[3] = 8888
print(bar) # [2, 3, 4, 8888, 6] - новый изменен
print(arr) # [1, 2, 3, 4, 5, 6, 7, 8] – старый без изменений!


Как же так? Ты говоришь про поверхностное копирование, но приводишь пример, где элементы копируются полностью? Да, я использовал числа для простоты примера, и может показаться, что копия полная, а не поверхностная. Докажу вам обратное:

list1 = [333, 444]
list2 = list1[:] # копия
id(list1[0]) == id(list2[0]) # True


Оба списка имеют один и тот же объект (по тому же самому адресу) в своем составе!
Просто числа – объекты неизменяемые, иными словами, когда вы присваиваете число, то не меняете содержимое объекта по текущему адресу, вы привязываете переменную к новому объекту в другом месте памяти. А равные числа выглядят одинаково, независимо от того, в каком месте памяти они лежат 🙄

Для наглядности, давайте в качестве элемента списка возьмем изменяемый тип, например, опять же список. Изменения в элементе списка list1 затронут копированный поверхностно список list2.

list1 = [ [], [] ]
list2 = list1[:]
list1[0].append('surprise')
print(list2[0]) # ['surprise']


Таким образом, поверхностная копия копирует только: количество элементов и их порядок, а сами элементы остаются ссылками на старые объекты. То есть если вы начнете добавлять, удалять, менять местами элементы list1, то порядок в list2 не пострадает. Но если вы сможете изменить внутреннее содержимое элементов списка list1, которые остались в list2, тогда оно будет затронуто в обоих местах.

Для полной глубокой копии используют библиотечную функцию deepcopy, которая рекурсивно копирует объекты:

from copy import deepcopy
list1 = [ [], [] ]
list2 = deepcopy(list1) #
👈
list1[0].append('surprise')
print(f"{list1 = }, {list2 = }")
# list1 = [['surprise'], []], list2 = [[], []] - изменение не отразилось
id(list1[0]) == id(list2[0]) # False – разные объекты!


Надеюсь, что объяснил. Если что, спрашивайте еще.
Вот так новость! Python выходит на первое место в известном рейтинге языков программирования TIOBE! 🐍
🎁 takewhile: бери, пока дают

В модуле itertools есть функция takewhile. Она позволяет выбирать из последовательности значения до тех пор, пока условие возвращает True.

У нее два параметра: predicate – предикат, или иными словами проверяющая функция и iterable – последовательность – это может быть список, кортеж, строка, итератор и т.п.

Код функции выглядит достаточно просто, примерно так:

def takewhile(predicate, iterable):
for x in iterable:
if predicate(x):
yield x
else:
break

Как только predicate вернет эквивалент False на очередном элементе, то цикл остановится.
Функция возвращает не список, а генератор. Если нужен список, то оборачиваем вызов в функцию list.

Давайте по примерам. Выбрать из списка все первые элементы меньше 10:

from itertools import takewhile
numbers = [1, 2, 5, 9, 11, 42, 3]
numbers10 = list(takewhile(lambda x: x < 10, numbers))
print(numbers10) # [1, 2, 5, 9]


Заметьте, что 3 не попала в результат, потому что встретив число 11 takewhile остановила работу. Это отличает ее от filter, которая всегда пройдет до конца.

Пример 2. Количество пробелов в начале строки:

string = " Wat? "
print(sum(1 for _ in takewhile(lambda x: x == ' ', string))) # 7
У takewhile в модуле itertools есть брат близнец из параллельной вселенной. Функция dropwhile, как можно понять из названия, будет пропускать элементы коллекции, пока выполняется условия. Ее аргументы такие же, как и у рассмотренной выше takewhile: функция проверки каждого элемента и собственно итерируемый объект.

Пока условие выполняется, функция dropwhile шагает далее. Как только впервые условие не выполнится для очередного элемента, функция начнет отдавать из себя элементы (включая этот) по очереди до конца.
dropwhile – это также генератор, и если нужен список, то обернем ее вызов в list.

Давайте посмотрим пример. Допустим дана последовательность чисел и нужно отбросить все отрицательные числа с начала:

from itertools import dropwhile

numbers = [-5, -4, -1, 0, 10, -10, 22]
numbers1 = list(dropwhile(lambda x: x < 0, numbers))
print(numbers1) # [0, 10, -10, 22]

Число -10 осталась, потому что после встречи с первым неотрицательным числом, функция отдаст его и абсолютно все последующие элементы (в отличие от filter).

Примечательно, что для любой функции и любой коллекции на входе выполняется условие:
numbers == list(takewhile(func, numbers)) + list(dropwhile(func, numbers)) # True
(Это если, конечно, не возникло ошибок)
Какой результат будет при преобразовании строки "False" к булеву типу?
Ответ:
Anonymous Quiz
29%
False
63%
True
8%
Ошибка