Субботний пакет
Репозиторий питонячих пакетов (PyPI) включает аж 300 тысяч проектов. Среди них есть прикладные (requests) и инфраструктурные (pip), полезные (redis) и не очень (insultgenerator). Есть большие и маленькие, надежные и бажные, набирающие обороты и давно заброшенные. Всякие есть.
Я подумал, что было бы неплохо писать об одном из них раз в неделю, по субботам — в рубрике #пакетик. Так что если вы автор какого-нибудь классного пакета — дайте знать в личку (@nalgeon). Может, одна из суббот станет вашей ツ
Репозиторий питонячих пакетов (PyPI) включает аж 300 тысяч проектов. Среди них есть прикладные (requests) и инфраструктурные (pip), полезные (redis) и не очень (insultgenerator). Есть большие и маленькие, надежные и бажные, набирающие обороты и давно заброшенные. Всякие есть.
Я подумал, что было бы неплохо писать об одном из них раз в неделю, по субботам — в рубрике #пакетик. Так что если вы автор какого-нибудь классного пакета — дайте знать в личку (@nalgeon). Может, одна из суббот станет вашей ツ
Естественная сортировка
Мой сегодняшний выбор — пакет Сета Мортона natsort, который сортирует строки привычным для человека образом.
Допустим, у нас есть список важных гостей. Он в легком беспорядке:
Отсортируем:
Порядка не прибавилось ツ А вот как будет с
Другое дело!
#пакетик
Мой сегодняшний выбор — пакет Сета Мортона natsort, который сортирует строки привычным для человека образом.
Допустим, у нас есть список важных гостей. Он в легком беспорядке:
data = [
"4 - Дуглас",
"2 - Клер",
"11 - Зоя",
"1 - Френк",
"31 - Питер",
]
Отсортируем:
>>> sorted(data)
['1 - Френк', '11 - Зоя', '2 - Клер', '31 - Питер', '4 - Дуглас']
Порядка не прибавилось ツ А вот как будет с
natsort
:>>> import natsort
>>> natsort.natsorted(data)
['1 - Френк', '2 - Клер', '4 - Дуглас', '11 - Зоя', '31 - Питер']
Другое дело!
#пакетик
«Отнаследовать» функцию от существующей
Некоторые справедливо заметили, что если формат исходной строки заранее известен, то отсортировать список можно через стандартную
Чтобы добавить семантичности и не таскать везде дополнительный параметр
Есть и более лакончиный способ сделать это — через
Таким образом,
* Строго говоря, не функцию, а вызываемый объект, у которого определен дандер
#stdlib
Некоторые справедливо заметили, что если формат исходной строки заранее известен, то отсортировать список можно через стандартную
sorted()
:data = [
"4 - Дуглас",
"2 - Клер",
"11 - Зоя",
"1 - Френк",
"31 - Питер",
]
def _key(src):
parts = src.partition(" - ")
return int(parts[0])
>>> sorted(data, key=_key)
['1 - Френк', '2 - Клер', '4 - Дуглас', '11 - Зоя', '31 - Питер']
Чтобы добавить семантичности и не таскать везде дополнительный параметр
key
, можно создать собственную функцию на основе sorted()
:def natsorted(iterable, reverse=False):
return sorted(iterable, key=_key, reverse=reverse)
>>> natsorted(data)
['1 - Френк', '2 - Клер', '4 - Дуглас', '11 - Зоя', '31 - Питер']
Есть и более лакончиный способ сделать это — через
functools.partial()
:import functools
natsorted = functools.partial(sorted, key=_key)
partial()
создает новую функцию* на основе существующей. При этом можно «зафиксировать» один или несколько параметров (мы зафиксировали key
), разрешив менять остальные (iterable
и reverse
в нашем случае).Таким образом,
partial()
помогает создавать узкоспециализированные функции на базе более универсальных.* Строго говоря, не функцию, а вызываемый объект, у которого определен дандер
__call__
— его можно вызывать, как будто это функция.#stdlib
Планировщик задач
В стандартной библотеке есть встроенный планировщик задач (а чего вообще в ней нет?). Подробно расскажу в другой раз, но в целом он, скажем так, не слишком юзер-френдли.
Поэтому Дэн Бэйдер сделал schedule — «планировщик для людей». Смотрите, какой милый:
Ноль зависимостей, чистый и великолепно документированный код, примеры на все случаи жизни.
#пакетик
В стандартной библотеке есть встроенный планировщик задач (а чего вообще в ней нет?). Подробно расскажу в другой раз, но в целом он, скажем так, не слишком юзер-френдли.
Поэтому Дэн Бэйдер сделал schedule — «планировщик для людей». Смотрите, какой милый:
import schedule
import time
def job():
print("I'm working...")
schedule.every().hour.do(job)
schedule.every(5).to(10).minutes.do(job)
schedule.every().day.at("10:30").do(job)
while True:
schedule.run_pending()
time.sleep(1)
Ноль зависимостей, чистый и великолепно документированный код, примеры на все случаи жизни.
#пакетик
Задачка: неэффективный планировщик
Субботний пакет-планировщик вскрыл интересное искажение у некоторых подписчиков. Давайте проверим, есть ли оно у вас ツ
Пусть есть задача, которую мы хотим выполнять каждую минуту:
И есть планировщик. Он ужасно плохо написан, и тупит 0.2 секунды при каждом запуске:
Мы гоняем планировщик в бесконечном цикле каждую секунду:
И — о ужас — с каждым запуском планировщик все сильнее запаздывает:
Вопрос: насколько сильно будет опаздывать запуск задачи
Опрос следует.
#задачка
Субботний пакет-планировщик вскрыл интересное искажение у некоторых подписчиков. Давайте проверим, есть ли оно у вас ツ
Пусть есть задача, которую мы хотим выполнять каждую минуту:
def job():
print("Executing job")
И есть планировщик. Он ужасно плохо написан, и тупит 0.2 секунды при каждом запуске:
class Scheduler:
def run_pending(self):
time.sleep(0.2)
print(dt.datetime.now())
// запускает job(),
// если наступила новая минута
Мы гоняем планировщик в бесконечном цикле каждую секунду:
sched = Scheduler()
while True:
sched.run_pending()
time.sleep(1)
И — о ужас — с каждым запуском планировщик все сильнее запаздывает:
2021-05-24 15:19:01.9
2021-05-24 15:19:03.1
2021-05-24 15:19:04.3
2021-05-24 15:19:05.6
2021-05-24 15:19:06.8
2021-05-24 15:19:08.0
2021-05-24 15:19:09.2
2021-05-24 15:19:10.4
Вопрос: насколько сильно будет опаздывать запуск задачи
job()
? Напомню, она должна запускаться каждую минуту.Опрос следует.
#задачка
Насколько сильно будет опаздывать запуск задачи?
Final Results
35%
Максимум на 1 секунду
16%
Максимум на 1 минуту
1%
Максимум на 1 час
48%
Все больше и больше, до бесконечности
Поэлементно сравнить коллекции
Однажды мы уже смотрели, как множества помогают быстро проверить, входит ли элемент в коллекцию.
Конечно, это не единственная возможность. Множества в питоне идеально подходят, чтобы поэлементно сравнивать коллекции.
Допустим, мы ведем учет посетителей:
И хотим узнать, кто приходил в январе и феврале. Нет ни малейшего желания писать вложенный цикл с перебором
Были в январе и феврале:
В январе или марте:
В феврале, но не в марте:
В январе или феврале, но не в оба месяца:
Все эти операции выполняются за линейное время
Кроме обычных множеств бывают замороженные (их нельзя менять):
Множество можно слепить из любого iterable-типа. Например, из строки:
Или даже из диапазона:
В общем, полезная штука.
#stdlib
Однажды мы уже смотрели, как множества помогают быстро проверить, входит ли элемент в коллекцию.
Конечно, это не единственная возможность. Множества в питоне идеально подходят, чтобы поэлементно сравнивать коллекции.
Допустим, мы ведем учет посетителей:
jan = ["Питер", "Клер", "Френк"]
feb = ["Френк", "Зоя", "Дуглас"]
mar = ["Клер", "Питер", "Зоя"]
И хотим узнать, кто приходил в январе и феврале. Нет ни малейшего желания писать вложенный цикл с перебором
jan
и feb
. Намного приятнее (и быстрее) использовать множества.jan = {"Питер", "Клер", "Френк"}
feb = {"Френк", "Зоя", "Дуглас"}
mar = {"Клер", "Питер", "Зоя"}
Были в январе и феврале:
>>> jan & feb
{'Френк'}
В январе или марте:
>>> jan | mar
{'Питер', 'Клер', 'Зоя', 'Френк'}
В феврале, но не в марте:
>>> feb - mar
{'Френк', 'Дуглас'}
В январе или феврале, но не в оба месяца:
>>> jan ^ feb
{'Питер', 'Клер', 'Зоя', 'Дуглас'}
Все эти операции выполняются за линейное время
O(n)
вместо квадратичного O(n²)
, как было бы на списках.Кроме обычных множеств бывают замороженные (их нельзя менять):
>>> visitors = frozenset().union(jan, feb, mar)
>>> visitors
frozenset({'Питер', 'Клер', 'Зоя', 'Френк', 'Дуглас'})
Множество можно слепить из любого iterable-типа. Например, из строки:
>>> frozenset('abcde')
frozenset({'b', 'd', 'e', 'c', 'a'})
Или даже из диапазона:
>>> set(range(1, 10))
{1, 2, 3, 4, 5, 6, 7, 8, 9}
В общем, полезная штука.
#stdlib
Счетчик для огромных коллекций
В стандартной библиотеке есть класс Counter. Он отлично подходит, чтобы считать количество объектов разных типов. Но что делать, если объектов миллиарды, и счетчик просто не помещается в оперативную память?
Поможет bounter — это счетчик, который предоставляет схожий интерфейс, но внутри построен на вероятностных структурах данных. За счет этого он занимает в 30–250 раз меньше памяти, но может (слегка) привирать.
Ноль зависимостей, питон 3.3+
#пакетик
В стандартной библиотеке есть класс Counter. Он отлично подходит, чтобы считать количество объектов разных типов. Но что делать, если объектов миллиарды, и счетчик просто не помещается в оперативную память?
Поможет bounter — это счетчик, который предоставляет схожий интерфейс, но внутри построен на вероятностных структурах данных. За счет этого он занимает в 30–250 раз меньше памяти, но может (слегка) привирать.
from bounter import bounter
counts = bounter(size_mb=128)
counts.update(["a", "b", "c", "a", "b"])
>>> counts.total()
5
>>> counts["a"]
2
Ноль зависимостей, питон 3.3+
#пакетик
Главный критерий хорошего кода
Хороший код — понятный и непрожорливый до ресурсов. Давайте поговорим об этом.
Время на понимание
Главный критерий хорошего кода — это время T, которое требуется не-автору, чтобы разобраться в коде. Причем разобраться не на уровне «вроде понятно», а достаточно хорошо, чтобы внести изменения и ничего не сломать.
Чем меньше T, тем лучше код.
Допустим, Нина и Витя реализовали одну и ту же фичу, а вы хотите ее доработать. Если разберетесь в коде Нины за 10 минут, а в коде Вити за 30 минут — код Нины лучше. Неважно, насколько у Вити чистая архитектура, функциональный подход, современный фреймворк и всякое такое.
T-метрика для начинающего и опытного программиста отличается. Поэтому имеет смысл ориентироваться на средний уровень коллег, которые будут работать с кодом. Если у вас в коллективе люди трудятся 10+ лет, и каждый написал по компилятору — даже очень сложный код будет иметь низкое T. Если у вас огромная текучка, а нанимают вчерашних студентов — код должен быть совершенно дубовым, чтобы T не зашкаливало.
Напрямую T не очень-то померяешь, поэтому часто отслеживают вторичные метрики, которые влияют на T:
— соответствие код-стайлу (black для питона),
— «запашки» в коде (pylint, flake8),
— цикломатическую сложность (mccabe),
— зависимости между модулями (import-linter).
Плюс код-ревью.
Количество ресурсов
Второй критерий хорошего кода — количество ресурсов R, которое он потребляет (времени, процессора, памяти, диска). Чем меньше R, тем лучше код.
Если Нина и Витя реализовали фичу с одинаковым T, но код Нины работает за O(n), а код Вити за O(n²) (при одинаковом потреблении прочих ресурсов) — код Нины лучше.
Насчет ситуации «пожертвовать понятностью ради скорости». Для каждой задачи есть порог потребления ресурсов R0, в который должно уложиться решение. Если R < R0, не надо ухудшать T ради дальнейшего сокращения R.
Если некритичный сервис обрабатывает запрос за 50мс — не надо переписывать его с питона на C, чтобы сократить время до 5мс. И так достаточно быстро.
Иногда, если ресурсы ограничены, или исходные данные большие — не получается достичь R < R0 без ухудшения T. Тогда действительно приходится жертвовать понятностью. Но:
1) Это последний вариант, когда все прочие уже испробованы.
2) Участки кода, где T↑ ради R↓, должны быть хорошо изолированы.
3) Таких участков должно быть мало.
4) Они должны быть подробно документированы.
Итого
Мнемоника хорошего кода:
Оптимизируйте T, следите за R. Коллеги скажут вам спасибо.
#код
Хороший код — понятный и непрожорливый до ресурсов. Давайте поговорим об этом.
Время на понимание
Главный критерий хорошего кода — это время T, которое требуется не-автору, чтобы разобраться в коде. Причем разобраться не на уровне «вроде понятно», а достаточно хорошо, чтобы внести изменения и ничего не сломать.
Чем меньше T, тем лучше код.
Допустим, Нина и Витя реализовали одну и ту же фичу, а вы хотите ее доработать. Если разберетесь в коде Нины за 10 минут, а в коде Вити за 30 минут — код Нины лучше. Неважно, насколько у Вити чистая архитектура, функциональный подход, современный фреймворк и всякое такое.
T-метрика для начинающего и опытного программиста отличается. Поэтому имеет смысл ориентироваться на средний уровень коллег, которые будут работать с кодом. Если у вас в коллективе люди трудятся 10+ лет, и каждый написал по компилятору — даже очень сложный код будет иметь низкое T. Если у вас огромная текучка, а нанимают вчерашних студентов — код должен быть совершенно дубовым, чтобы T не зашкаливало.
Напрямую T не очень-то померяешь, поэтому часто отслеживают вторичные метрики, которые влияют на T:
— соответствие код-стайлу (black для питона),
— «запашки» в коде (pylint, flake8),
— цикломатическую сложность (mccabe),
— зависимости между модулями (import-linter).
Плюс код-ревью.
Количество ресурсов
Второй критерий хорошего кода — количество ресурсов R, которое он потребляет (времени, процессора, памяти, диска). Чем меньше R, тем лучше код.
Если Нина и Витя реализовали фичу с одинаковым T, но код Нины работает за O(n), а код Вити за O(n²) (при одинаковом потреблении прочих ресурсов) — код Нины лучше.
Насчет ситуации «пожертвовать понятностью ради скорости». Для каждой задачи есть порог потребления ресурсов R0, в который должно уложиться решение. Если R < R0, не надо ухудшать T ради дальнейшего сокращения R.
Если некритичный сервис обрабатывает запрос за 50мс — не надо переписывать его с питона на C, чтобы сократить время до 5мс. И так достаточно быстро.
Иногда, если ресурсы ограничены, или исходные данные большие — не получается достичь R < R0 без ухудшения T. Тогда действительно приходится жертвовать понятностью. Но:
1) Это последний вариант, когда все прочие уже испробованы.
2) Участки кода, где T↑ ради R↓, должны быть хорошо изолированы.
3) Таких участков должно быть мало.
4) Они должны быть подробно документированы.
Итого
Мнемоника хорошего кода:
T↓ R<R0
Оптимизируйте T, следите за R. Коллеги скажут вам спасибо.
#код
Универсальные оповещения
Есть куча способов отправлять уведомления — от проверенного SMTP и удобного Telegram до смс и специальных приложений для мобилок вроде Pushover.
Обычно для этого используют 3rd-party библиотеку соответствующего провайдера. Но есть более удобный способ — пакет notifiers от Ора Карми. Он предоставляет простой универсальный интерфейс для отправки сообщений через любой сервис.
Например, через телеграм:
Поддерживается аж 16 провайдеров, а интерфейс один — метод
Питон 3.6+
#пакетик
Есть куча способов отправлять уведомления — от проверенного SMTP и удобного Telegram до смс и специальных приложений для мобилок вроде Pushover.
Обычно для этого используют 3rd-party библиотеку соответствующего провайдера. Но есть более удобный способ — пакет notifiers от Ора Карми. Он предоставляет простой универсальный интерфейс для отправки сообщений через любой сервис.
Например, через телеграм:
import notifiers
token = "bot_token"
chat_id = 1234
tg = notifiers.get_notifier("telegram")
tg.notify(message="Привет!", token=token, chat_id=chat_id)
Поддерживается аж 16 провайдеров, а интерфейс один — метод
.notify()
. И никаких дополнительных 3rd-party библиотек. Удобно!Питон 3.6+
#пакетик
Современный HTTP-клиент
Мало у какого языка такая нажористая стандартная библиотека, как у питона. Но все равно для работы с HTTP люди пользуются сторонним пакетом requests.
А я вот отказался от него в пользу замечательного httpx от Тома Кристи. Синхронный и асинхронный интерфейсы, поддержка wsgi/asgi, плюс все фичи requests — и совместимость с ним!
Можно заменить requests → httpx, и все продолжит работать:
Питон 3.6+
#пакетик
Мало у какого языка такая нажористая стандартная библиотека, как у питона. Но все равно для работы с HTTP люди пользуются сторонним пакетом requests.
А я вот отказался от него в пользу замечательного httpx от Тома Кристи. Синхронный и асинхронный интерфейсы, поддержка wsgi/asgi, плюс все фичи requests — и совместимость с ним!
Можно заменить requests → httpx, и все продолжит работать:
>>> import httpx
>>> r = httpx.get("http://httpbingo.org/json")
>>> r.status_code
200
>>> r.headers["content-type"]
'application/json; encoding=utf-8'
>>> r.json()["slideshow"]["title"]
'Sample Slide Show'
Питон 3.6+
#пакетик
Разбор текста по шаблону
Все знают, как в питоне форматировать текст по шаблону:
А благодаря библиотеке parse от Ричарда Джонса, с такой же легкостью можно разбирать текст обратно по переменным:
parse по большей части поддерживает стандартный питонячий мини-язык форматирования, так что новый синтаксис учить не придется.
Внутри работает на регулярках. Ноль зависимостей, питон 2 и 3
#пакетик
Все знают, как в питоне форматировать текст по шаблону:
import datetime as dt
date = dt.date(2020, 11, 20)
who = "Френк"
count = 42
tmpl = "{:%Y-%m-%d}: {} и его {:d} друга вылетели в Копенгаген"
>>> tmpl.format(date, who, count)
'2020-11-20: Френк и его 42 друга вылетели в Копенгаген'
А благодаря библиотеке parse от Ричарда Джонса, с такой же легкостью можно разбирать текст обратно по переменным:
import parse
tmpl = "{:ti}: {} и его {:d} друга вылетели в Копенгаген"
txt = "2020-11-20: Френк и его 42 друга вылетели в Копенгаген"
>>> date, who, count = parse.parse(tmpl, txt)
>>> date
datetime.datetime(2020, 11, 20, 0, 0)
>>> who
'Френк'
>>> count
42
parse по большей части поддерживает стандартный питонячий мини-язык форматирования, так что новый синтаксис учить не придется.
Внутри работает на регулярках. Ноль зависимостей, питон 2 и 3
#пакетик
День списков
Давайте проведем тематический день! (если честно, то несколько)
Посвятим его структуре данных номер один в мире — массивам. Если вы еще не гуру алгоритмов и структур данных — гарантирую, что лучше поймете списки в питоне, их преимущества и ограничения. А если и так все знаете — освежите ключевые моменты ツ
Все знают, как работать со списком в питоне:
Наверняка вы знаете, что выборка элемента по индексу —
А знаете, за счет чего так быстро работает? Как внутри устроено? Опрос следует.
#stdlib
Давайте проведем тематический день! (если честно, то несколько)
Посвятим его структуре данных номер один в мире — массивам. Если вы еще не гуру алгоритмов и структур данных — гарантирую, что лучше поймете списки в питоне, их преимущества и ограничения. А если и так все знаете — освежите ключевые моменты ツ
Все знают, как работать со списком в питоне:
>>> guests = ["Френк", "Клер", "Зоя"]
>>> guests[1]
'Клер'
Наверняка вы знаете, что выборка элемента по индексу —
guests[idx]
— отработает очень быстро даже на списке из миллиона элементов. Более точно, выборка по индексу работает за константное время O(1)
— то есть не зависит от количества элементов в списке.А знаете, за счет чего так быстро работает? Как внутри устроено? Опрос следует.
#stdlib
Почему list[idx] работает за O(1)?
Final Results
48%
Конечно, знаю
32%
Понятия не имею
20%
Посмотрю результаты
Список = массив?
В основе питонячего списка лежит массив. Массив — это набор элементов (1) одинакового размера и (2) расположенных в памяти подряд друг за другом, без пропусков.
Раз элементы одинаковые и идут подряд, получить элемент массива по индексу несложно — достаточно знать адрес самого первого элемента («головы» массива).
Допустим, голова находится по адресу
Поскольку операция «получить значение по адресу» выполняется за константное время, то и выборка из массива по индексу выполняется за
Грубо говоря, питонячий список именно так и устроен. Он хранит указатель на голову массива и количество элементов в массиве. Количество хранится отдельно, чтобы функция
Все хорошо, но есть пара проблем:
— все элементы массива одного размера, а список умеет хранить разные (true/false, числа, строки разной длины);
— массив имеет фиксированную длину, а в список можно добавить сколько угодно элементов.
Чуть позже посмотрим, как их решить.
#stdlib
В основе питонячего списка лежит массив. Массив — это набор элементов (1) одинакового размера и (2) расположенных в памяти подряд друг за другом, без пропусков.
Раз элементы одинаковые и идут подряд, получить элемент массива по индексу несложно — достаточно знать адрес самого первого элемента («головы» массива).
Допустим, голова находится по адресу
0x00001234
, а каждый элемент занимает 8 байт. Тогда элемент с индексом idx находится по адресу 0x00001234 + idx*8
(картинка прилагается).Поскольку операция «получить значение по адресу» выполняется за константное время, то и выборка из массива по индексу выполняется за
O(1)
.Грубо говоря, питонячий список именно так и устроен. Он хранит указатель на голову массива и количество элементов в массиве. Количество хранится отдельно, чтобы функция
len()
тоже отрабатывала за O(1)
, а не считала каждый раз фактическое количество элементов списка.Все хорошо, но есть пара проблем:
— все элементы массива одного размера, а список умеет хранить разные (true/false, числа, строки разной длины);
— массив имеет фиксированную длину, а в список можно добавить сколько угодно элементов.
Чуть позже посмотрим, как их решить.
#stdlib
Ну очень примитивный список
Лучший способ освоить структуру данных — реализовать ее с нуля. К сожалению, питон плохо подходит для таких низкоуровненых структур как массив, потому что не дает явно работать с указателями (адресами в памяти).
Но кое-что можно сделать:
Наш самописный список имеет фиксированную вместимость (
Модуль
Завтра продолжим ツ
#stdlib
Лучший способ освоить структуру данных — реализовать ее с нуля. К сожалению, питон плохо подходит для таких низкоуровненых структур как массив, потому что не дает явно работать с указателями (адресами в памяти).
Но кое-что можно сделать:
class OhMyList:
def __init__(self):
self.length = 0
self.capacity = 8
self.array = (self.capacity * ctypes.py_object)()
def append(self, item):
self.array[self.length] = item
self.length += 1
def __len__(self):
return self.length
def __getitem__(self, idx):
return self.array[idx]
Наш самописный список имеет фиксированную вместимость (
capacity
= 8 элементов) и хранит элементы в массиве array
.Модуль
ctypes
дает доступ к сишным структурам, на которых построена стандартная библиотека. В даннам случае мы используем его, чтобы создать массив размером в capacity
элементов.Завтра продолжим ツ
#stdlib
Список = массив указателей
Итак, список моментально выбирает элемент по индексу, потому что внутри у него массив. А массив такой быстрый, потому что все элементы у него одинакового размера.
Но при этом в списке элементы могут быть очень разные:
Чтобы решить эту задачку, придумали хранить в массиве не сами значения, а указатели на них. Элемент массива — адрес в памяти, а если обратиться по адресу — получишь настоящее значение (картинка прилагается).
Поскольку указатели фиксированного размера (8 байт на современных 64-битных процессорах), то все прекрасно работает. Да, получается, что вместо одной операции (получить значение из элемента массива) мы делаем две:
1. Получить адрес из элемента массива.
2. Получить значение по адресу.
Но это все еще константное время O(1).
#stdlib
Итак, список моментально выбирает элемент по индексу, потому что внутри у него массив. А массив такой быстрый, потому что все элементы у него одинакового размера.
Но при этом в списке элементы могут быть очень разные:
guests = ["Френк", "Клер", "Зоя", True, 42]
Чтобы решить эту задачку, придумали хранить в массиве не сами значения, а указатели на них. Элемент массива — адрес в памяти, а если обратиться по адресу — получишь настоящее значение (картинка прилагается).
Поскольку указатели фиксированного размера (8 байт на современных 64-битных процессорах), то все прекрасно работает. Да, получается, что вместо одной операции (получить значение из элемента массива) мы делаем две:
1. Получить адрес из элемента массива.
2. Получить значение по адресу.
Но это все еще константное время O(1).
#stdlib