Python: неочевидное и вероятное

211945977c833ba93408b0cf9550e441.gif

Python известен своей простотой и предсказуемостью, но за этой доступностью скрываются интересные и неочевидные особенности, способные удивить программистов с базовым опытом (а если повезет, то и опытных). В этой статье мы рассмотрим несколько таких «фокусов» и тонкостей, чтобы глубже понять внутреннюю логику и философию языка.

2bae96b2cc918bd1ca3696b9dff330fa.gif

Оператор побитовой инверсии: почему bool (~-True) — это False?

В Python оператор побитовой инверсии (bitwise NOT) обозначается символом ~ и применяется к целым числам, инвертируя каждый бит. Результат операции для положительных чисел соответствует формуле ~x = -(x + 1). Например:

x = 5  # Бинарное представление: 00000101
result = ~x  # Результат: -(x + 1), т.е. -6 (в двоичном представлении: 11111010)
print(result)  # Вывод: -6

Рассмотрим, как из bool(~-True) получается False:

result = bool(~-True)
print(result)  # Вывод: False

Визуально этот процесс можно отобразить так:

e90f98449a37885964e3e34916e4fb82.gif

Помимо этого, оператор ~ можно использовать, но лучше так конечно не делать, в преобразовании индекса последовательностей, например таких как строки, кортежи и списки. Рассмотрим вот такой код:

# Для списка
lst = [10, 20, 30] 
print(lst[~0]) # Вывод: 30 | list[~0] эквивалентно list[-1]

# Для строки
word = "HABR"
print(word[~1]) # Вывод: B | list[~1] эквивалентно list[-2]

# Для кортежа
tup = (1, 2, 3)
print(tup[~2]) # Вывод: 1 | list[~2] эквивалентно list[-3]

В каком-то смысле, если образно представить, то оператор ~ при работе с индексами можно рассматривать как способ инвертировать направление отсчёта индексов:

cb662390c0dd6336928b4c6c76296085.gif

Это действительно похоже на то, как если бы отсчёт индексов «поменялся» с начала на конец.

Неочевидное поведение встроенных функций: почему all ([]) — True, а any ([]) — False?

all() для пустого списка [] возвращает True:

print(all([])) # Вывод: True

Это поведение можно объяснить следующим образом: функция all() для пустого списка [] возвращает True, поскольку в пустом списке нет элементов, которые могли бы быть ложными и опровергнуть утверждение, что все элементы истинны. Если с этим примером всё ясно, то следующий уже можно назвать «парадоксом вложенных списков»:

bdcbae9d29cacfaa7abb69b6c8ff28d8.gif

Поговорим о any()any() для пустого списка [] возвращает False:

print(any([])) # Вывод: False

Это поведение можно объяснить следующим образом: функция any() возвращает True, если хотя бы один элемент последовательности является истинным (не False). Поскольку в пустой последовательности нет элементов, удовлетворяющих этому условию, функция any() возвращает False. Добавим в копилку «парадоксов»:

d63a187ac4e1057f397cebde56c3d1ff.gif

Разбавим статью неочевидностями, которые могут пригодиться в повседневной работе с кодом, с функциями isinstance() и range(). Говоря о isinstance(), нередко встречается такой код:

num = 10

if isinstance(num, int) or isinstance(num, float):
	print("Habr") # Вывод: Habr

Здесь isinstance() используется дважды. Но, эта функция может принимать более одного параметра для проверки типов:

num = 10 

if isinstance(num, (int, float)): 
	print("Habr") # Вывод: Habr

(int, float) — это кортеж, состоящий из двух параметров: типов int и float. Но это ещё не всё. Вместо кортежа можно использовать union operator:

num = 10

if isinstance(num, int | float):
	print("Habr") # Вывод: Habr

Рассмотрим на примере небольшой задачки: дан список с разными типами чисел. Нужно найти сумму всех положительных целых и дробных чисел. Решение может быть следующим:

nums = [42, 3.14, (2+3j), -5, 10, '23']

# Суммируем только положительные целые и дробные числа
result = sum(i for i in nums if isinstance(i, int | float) and i > 0)

print(result)  # Вывод: 55.14

Кроме этого,  isinstance() можно использовать в дебаггинге:

num = 10

print(f'{isinstance(num, int) = }') # Вывод: isinstance(num, int) = True
print(f'{isinstance(num, float) = }') # Вывод: isinstance(num, float) = False

Поговорим о range(). В Python с функцией range() можно использовать срезы для получения элементов из последовательности:

result = list(range(20)[::2])
print(result) # Вывод: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]


# Предыдущий код тоже самое, что и:
result = list(range(0, 20, 2))
print(result) # Вывод: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]


# А вот это уже для строк:
text = "Hello Habra World"
for i in range(len(text))[::6]:
	print(text[i], end=' ') # Собрали первую букву каждого слова, Вывод: H H W

Небольшая шпаргалка по срезам в range():

result_step = list(range(15)[::5])
print(result_step) # Вывод: [0, 5, 10]


result_slice = list(range(15)[:5:])
print(result_slice) # Вывод: [0, 1, 2, 3, 4]


result_start = list(range(15)[5::])
print(result_start) # Вывод: [5, 6, 7, 8, 9, 10, 11, 12, 13, 14]

И теперь, благодаря срезам, решение первой задачи Проекта Эйлера может претендовать на звание самого элегантного:

print(sum(set(range(1000)[::3]) | set(range(1000)[::5]))) # 233168

Фокусы списков в Python

Рассмотрим вот такой код:

lst = [[]] * 5 
print(lst) # Вывод: [[], [], [], [], []]

Каждый элемент в lst выглядит как отдельный пустой список, но это не так. Все элементы в lst указывают на один и тот же список в памяти:

lst[0].append("Habr")
print(lst) # Вывод: [['Habr'], ['Habr'], ['Habr'], ['Habr'], ['Habr']]

Чтобы окончательно убедиться в этом, можно посмотреть на id каждого элемента внутри списка:

5b9225b8369d4b9bc7f3120ad9cc5a5a.gif

У всех пяти элементов внутри lst один и тот же id. Обойти это можно следующим образом:

lst = [[] for _ in range(5)]
print(lst) # Вывод: [[], [], [], [], []] | id каждого элемента внутри будет разным


lst[0].append("Habr")
print(lst) # Вывод: [['Habr'], [], [], [], []]

А теперь посмотрим, что значит список, содержащий ссылку на самого себя:

lst = []
lst.append(lst)
print(lst) # Вывод: [[...]]

В пустой список lst добавляется сам lst. Теперь список содержит ссылку на самого себя. И при попытке вывести список, Python распознает, что список содержит рекурсивную ссылку, и вместо бесконечного вывода выводит:  [[...]]. Мы можем заглянуть внутрь, и удостовериться так ли это или нет:

print(lst[0] is lst) # Вывод: True

Разумеется, продолжать «заглядывать внутрь» можно бесконечно долго:

d32651c1cacbc75a3aa410b64c2a9a8d.gif

Двуликие True и False: «скрытая природа» булевых значений

В Python тип bool является подтипом типа int. Это означает, что логические значения True и False представляют собой целые числа 1 и 0 соответственно. Таким образом, объекты типа bool могут использоваться в арифметических операциях так же, как и целые числа. Например:

print(True + False + True) # Вывод: 2
print("Habr"[False]) # Вывод: H
print("Habr"[True+True]) # Вывод: b
print("Habr"[-True]) # Вывод: r
print(False == False in [False]) # Вывод: True

Это свойство булевых значений полезно, например, в подсчете количества элементов, удовлетворяющих условию, с использованием функций вроде sum:

lst = [True, False, True] 
print(sum(lst)) # Вывод: 2

А вот так, например, выглядит вычисление первых десяти чисел последовательности Фибоначчи, используя True и False в качестве первых двух чисел:

def get_fibonacci():
    """Получение первых десяти элементов 
    последовательности Фибоначчи 
    без прямого использования числовых значений."""
    
    nums = []
    a, b = False, True
    
    for _ in range(len("tennumbers")):
        nums.append(a)
        a, b = b, a + b
    
    return [int(i) if isinstance(i, bool) else i for i in nums]
    ''' Здесь хоть мы и конвертируем первые два элемента, 
    но делаем это не для вычисления, а для однотипного вывода результатов. 
    Иначе: [False, True, 1, 2, 3, 5, 8, 13, 21, 34]
    '''


print(get_fibonacci()) # Вывод: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

И даже решение этой задачи в одну строку (вопреки PEP-8 и здравому смыслу, но во имя эксперимента) выдаст желаемый результат:

print((lambda f: [f.append(f[-True] + f[--~True]) or f[-True] for _ in range(len("abcdefjh"))] and f)([int(False), int(True)]))
# Вывод: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

Всё это поведение булевых значений можно описать двумя строками кода:

isinstance(True, int) # Вернет True 
isinstance(False, int) # Вернет True

Или даже одной строкой кода:

# Родительский класс bool — это int:
bool.__bases__ # Вернет (,)

В завершение, стоит отметить важное выражение, отражающее один из ключевых аспектов философии Python:

isinstance(type, object) == isinstance(object, type) # Вывод: True

Это утверждение подчеркивает взаимосвязь между типами и объектами, демонстрируя гибкость и мощь динамической типизации языка.

© Habrahabr.ru