Oh My Py
2.65K subscribers
21 photos
55 links
Все о чистом коде на Python // antonz.ru
Download Telegram
Чистый код: ratio и его упоротые друзья

На днях мы использовали метод SequenceMatcher.ratio() из модуля difflib, чтобы оценить сходство двух строк.

А что бы вы сказали, если узнали, что у того же SequenceMatcher есть ещё методы quick_ratio() и real_quick_ratio()? С описанием «возвращает верхнюю границу ratio() довольно быстро» и «возвращает верхнюю границу ratio() очень быстро»?

Я бы сказал, что это плохой код. Если бы коллега принёс такое на ревью, я бы предложил подумать ещё ツ Либо ты нормально называешь эти методы, чтобы понятно было, когда какой использовать. Либо прячешь их в глубине модуля и не делаешь частью публичного API.

Конкретно в данном случае я бы сделал «быстрый» и «очень быстрый» методы приватными, потому что они нужны только для оптимизации работы других публичных методов difflib. Используются примерно так:

if matcher.real_quick_ratio() >= cutoff and matcher.quick_ratio() >= cutoff and matcher.ratio() >= cutoff:
...


Как вспомогательные методы — ладно. Но точно не в публичный интерфейс.

#код
Чистый код: единообразие в именах

Всякая книга про хороший код начинается с главы об именах переменных и функций. Но каждый, кто работал с большим проектом, знает — хороших имён недостаточно. Важно ещё, чтобы они были единообразными во всём проекте.

Посмотрим на питоновский модуль difflib, который помогал нам сравнивать строки:

find_longest_match() находит самый длинный совпадающий кусок между двуми последовательностями и возвращает match — объект с совпадением и дополнительной информацией.

get_matching_blocks() находит все совпадения между двумя последовательностями и возвращает список из match.

get_close_matches() находит слова, сильнее всего похожие на переданное слово, возвращает список строк.

По отдельности вроде все названия хороши и понятны. Но я утверждаю, что это — плохой код:

1️⃣ find_longest_match вовращает объект-match, как и следует из названия; и get_matching_blocks возвращает такие же объекты, хотя название намекает, что должны возвращаться какие-то blocks

2️⃣ get_close_matches, судя по названию, должен возвращать match, как find_longest_match — но возвращает строки

3️⃣ одна и та же по сути операция (поиск совпадений) в одном случае называется find, а в двух других — get

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

Уж на уровне одного модуля можно напрячься и сохранить единообразие? Я предложил бы такие имена:

find_longest_match()
find_all_matches()
find_similar_words()

#код
Чистый код: раздвоение личности у функции

Я решил не терзать вас больше именованными кортежами, поэтому последнюю заметку серии выложил на хабре.

А сегодня очередной выпуск «чистого кода». Есть в модуле collections класс OrderedDict. Это обычный словарь, только помнит порядок, в котором добавлялись ключи:

from collections import OrderedDict

d = OrderedDict()
d["Френк"] = "наглый"
d["Клер"] = "хитрая"
d["Питер"] = "тупой"

>>> d.keys()
odict_keys(['Френк', 'Клер', 'Питер'])


Ничего особо интересного в упорядоченном словаре нет — тем более, что начиная с версии 3.6 обычный словарь тоже сохраняет порядок ключей.

Но есть у него любопытный метод move_to_end():

>>> d.move_to_end("Френк")
>>> d.keys()
odict_keys(['Клер', 'Питер', 'Френк'])


Всё вроде понятно, метод передвигает указанный ключ в конец словаря. Логично предположить, что должна существовать парная операция — передвинуть в начало. Интересно, как она называется, наверно move_to_start() или что-то в этом роде. А вот и нет:

>>> d.move_to_end("Френк", False)
>>> d.keys()
odict_keys(['Френк', 'Клер', 'Питер'])


То есть, чтобы передвинуть ключ в начало, мы делаем move_to_end(False). Это как если бы login(False) выполнял logout(). Это как если бы left(False) выполнял right(). Настолько хрестоматийно плохо, что я не понимаю, как это оказалось в стандартной библиотеке.

Много лет назад Роберт Мартин написал в «Чистом коде»: если у функции есть переключатель, который кардинально меняет её поведение — функцию следует разделить на две:

d.move_to_end("Френк")
d.move_to_start("Френк")


Не вижу причин спорить с Мартином в данном случае.

#код
C — не обязательно быстро

Получил такой комментарий на заметку про быстрый и медленный алгоритмы:

> Мне кажется, тут не совсем корректное сравнение. sorted оптимизированная и написана на С, в то время как insort — просто питоновская функция. Она гоняет питоновские структурки и при любом раскладе будет работать медленно.

Это вообще популярная точка зрения, что если что-то написано на «быстром» C, то оно уж всяко будет быстрее, чем написанное на «медленном» Python.

Конечно же, это не так. Алгоритмы отличаются асимптотической сложностью — в нашем примере было O(n logn) против O(n²). В такой ситуации O(n logn) будет всегда быстрее для достаточно большого n, даже если написать его на джаваскрипте и интерпретировать встроенной в Windows js-машиной, а O(n²) написать на самом быстром в мире C.

Другое дело, что оговорка «для достаточно большого n» может оказаться решающей. Бывает, что асимптотически более быстрый алгоритм начинает выигрывать, скажем, при n > 10 млрд — а у вас в программе всегда n < 1 млн. Именно поэтому стоит реализовать и сравнить в действии оба алгоритма, если нет 100% уверенности.

А ещё бывает, что при одинаковой асимптотической сложности один алгоритм в 5 раз быстрее другого — потому что она такие мелочи игнорирует. И тут тоже без тестирования никуда.

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

P.S. Модуль bisect на самом деле реализован на C. Если интересно, как выглядит «сишная» часть питона, посмотрите — это один из самых простых модулей.

#код
💣 Автоматизация задач в Python-проекте

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

— проверить код линтерами,
— прогнать тесты с замером покрытия,
— запустить в докере,
...

JS-разработчикам повезло: у них в package.json есть специальная секция scripts для таких штук. Для Питона ничего подобного не предусмотрено. Но есть отличное решение:

https://antonz.ru/makefile/

#код
📦 Как сделать классный Python-пакет

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

Оказывается, ситуация давно изменилась, и делать библиотеки стало легко и приятно. Буквально так:

flit init
...
flit publish


Попробуйте: https://antonz.ru/packaging/

P.S. Если у вас есть собственная библиотека, которой не стыдно поделиться — присылайте в личку. Про самые интересные напишу отдельно.

#код
Зачем читать исходники

Я как-то писал, что в документацию питона добавили ссылки на исходники модулей. Читать их не только увлекательно, но и полезно.

Помните linecache.getline() из прошлого поста, который выбирает строчку файла по номеру?

>>> linecache.getline("answers.txt", 3)
'Проверили, проблема на вашей стороне'


Модуль не случайно называется linecache. При первом обращении к файлу linecache записывает его содержимое в кеш (в глобальную переменную cache). Именно из этого кеша getline() и выбирает строку по номеру. Благодаря кешу второй и последующие вызовы уже не читают файл и отрабатывают моментально.

# lines - список строк файла
cache[filename] = size, mtime, lines, fullname


И есть в модуле функция linecache.checkcache(). Вот её документация:

> Check the cache for validity. Use this function if files in the cache may have changed on disk, and you require the updated version.

Вроде понятно, проверяет и актуализирует кеш. А вот как выглядит исходник этой функции:

def checkcache(filename=None):
# проверка, обновился ли файл
# по сравнению с кешем
# и если обновился, то:
cache.pop(filename)


Оказывается, checkcache() не актуализирует, а очищает кеш! Из-за этого следующий вызов getline() отработает заметно медленнее: придётся заново начитывать весь файл.

Конкретно в случае с linecache это вряд ли станет большой проблемой, но представьте, какой был бы неприятный сюрприз, если бы речь шла о продакшен-кеше вашего приложения ツ

В любой непонятной ситуации читай исходники, как говорил Урбан Мюллер, автор языка Brainfuck.

#код
Ищем фильмы, книги и подкасты с помощью Python

У Apple есть API поиска по iTunes Store и другим каталогам. Очень простое, но мало кто за пределами экосистемы айос-разработчиков про него знает. Поэтому решил написать о нём — конечно, с примерами на питоне:

import requests

def search(term, media):
url = "https://itunes.apple.com/search"
payload = {"term": term, "media": media}
response = requests.get(url, params=payload)
response.raise_for_status()
results = response.json().get("results", [])
return results


Статья на хабре:
https://habr.com/ru/post/509192/

#код
Travis CI → GitHub Actions

В прошлом году я писал, как сделать классный Python-пакет. Там упоминаются полезные облачные сервисы: Travis CI для сборки, Coveralls для покрытия, Code Climate для качества кода.

Так вот, сдается мне, что Travis CI пора на покой. В 2020 году Гитхаб довел до ума свои Actions, и они просто бесподобны. Где еще вы настроите сборку и публикацию под Windows, Linux и macOS за десять минут?

Рекомендация этого года — GitHub Actions:
https://antonz.ru/github-actions/

#код
Простое против легкого

9 лет назад в докладе «Simple Made Easy» Рич Хикки рассказал о разнице между простым (simple) и легким (easy) в разработке софта. Стремление к простым программам (в противоположность легким) — самый важный, наверное, принцип разработки. И при этом совершенно непопулярный.

Simple — это о внутреннем устройстве программы, ее архитектуре. У простых программ мало внутренних зависимостей, движущихся частей, настроек. Антипод простой программы — сложная. Простая программа или сложная — это объективная характеристика.

Easy — это о том, насколько человеку легко работать с программой. Это субъективная характеристика: что мне легко, другому сложно, и наоборот. Антипод легкой программы — тяжелая.

Например, SQLite — легкая, но не простая. Внутри там ад, особенно в системе типов и взаимовлиянии многочисленных параметров. А Redis — простой. Но для многих не такой легкий, как SQLite, потому что непривычный. Docker — «легкий», но сложный. Kubernetes — тяжелый и адово сложный.

JavaScript — легкий, но очень сложно устроен. Python — тоже легкий и сложный, хотя и попроще джаваскрипта. Go — простой.

Модули стандартной библиотеки bisect и heapq — простые. Но не легкие, если вы не знаете алгоритмов, которые они реализуют. dataclasses и namedtuple созданы, чтобы быть легкими, но при этом очень сложные.

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

Разработчики предпочитают писать «легкие» программы, а не простые — потому что простые делать тяжело. Придется продумывать архитектуру, работать с ограничениями, много раз переписывать. Намного легче слепить из палочек и веточек, а сверху приделать «легкий» интерфейс.

Я очень хочу, чтобы в мире софта появлялось больше простых, а не «легких» программ и библиотек. А у вас есть любимые простые штуки?

#код
📦 Как сделать классный Python-пакет в 2021

В прошлом году я написал инструкцию, как сделать модный и современный питонячий пакет. Рекомендовал там использовать Travis CI.

А потом распробовал альтернативу — GitHub Actions. Это бесконечно крутой сервис, который использую теперь буквально для всего. Ну и для тестирования и публикации пакетов тоже, конечно.

Использовать Тревис больше нет никакого смысла. Поэтому вот новая версия руководства: https://antonz.ru/packaging/

#код
Главный критерий хорошего кода

Хороший код — понятный и непрожорливый до ресурсов. Давайте поговорим об этом.

Время на понимание

Главный критерий хорошего кода — это время 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. Коллеги скажут вам спасибо.

#код
Сложность алгоритмов

Одну и ту же задачу можно решить десятком разных способов. Как понять, какой из них лучше?

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

Считать точное количество операций слишком хлопотно. Поэтому его описывают как зависимость от числа N — количества исходных данных, на которых работает алгоритм.

Такую оценку называют «Big-O» или «асимптотическим анализом» (потому что она работает для больших значений N).

Вот распространенные оценки алгоритмов от более быстрых (мало операций) к более медленным (много операций):

Константная сложность O(1)

Время выполнения алгоритма не зависит от объема исходных данных. Идеальный алгоритм!

Например, выбрать элемент из списка по индексу:

lst = [random.random() for _ in range(n)]
idx = random.randint(0, n-1)
# O(1)
lst[idx]


Логарифмическая O(log n)

При увеличении n время выполнения алгоритма растет такими же темпами, как log(n). Логарифм растет медленно (log 1000000 ≈ 20), так что даже при больших n логарифмические алгоритмы работают достаточно быстро.

Например, найти элемент в отсортированном списке:

el = random.random()
sorted_lst = sorted(lst)
# O(log n)
bisect.bisect(sorted_lst, el)


Линейная O(n)

Время выполнения алгоритма растет такими же темпами, как n. Как правило, такие алгоритмы перебирают все исходные данные.

Например, найти элемент в неотсортированном списке:

el = random.random()
# O(n)
idx = lst.index(el)


Линейно-логарифмическая O(n log n)

Время выполнения алгоритма растет такими же темпами, как n × log(n). Алгоритм получается медленнее, чем O(n), но не слишком (логарифм n намного меньше n, помните?).

Например, отсортировать список:

# O(n log n)
sorted(lst)


Продолжение следует ツ Если что непонятно — спрашивайте в комментариях.

#код
Сложность алгоритмов: полиномиальные

Продолжаем рассматривать оценки скорости алгоритмов, которые часто встречаются на практике. С быстрыми (константные, логарифмические) и «условно-быстрыми» (линейные, линейно-логарифмические) мы разобрались. Продолжим «условно-медленными» — полиномиальными.

Квадратичная сложность O(n²)

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

Например, сравнить два списка по принципу «каждый элемент с каждым»:

# O(n²)
for a in lst1:
for b in lst2:
print(a > b)


Полиномиальная O(nᵏ)

Время выполнения растет как n в некоторой фиксированной степени k: , n⁴, n⁵.

С одной стороны, по сравнению с O(log n) это медленно. С другой, когда действительно сложную задачу можно решить за полиномиальное время — это успех.

Если слышали о проблеме «P vs NP», то P — как раз полиномиальные (= быстрые) алгоритмы, а NP — суперполиномиальные (= медленные). Так что для некоторых задач O(nᵏ) очень даже быстро ツ

Строго говоря, линейные (k=1) и квадратичные (k=2) алгоритмы — частные случаи полиномиальных.

Пример полиномиального алгоритма — наивное умножение двух матриц. Выполняется за O(n³):

# A размера n×m
# B размера m×p
for i in range(0, n):
for j in range(0, p):
sum_ = 0
for k in range(0, m):
sum_ += A[i][k] * B[k][j]
C[i][j] = sum_


Продолжение следует ツ Если что непонятно — спрашивайте в комментариях.

#код
Сложность алгоритмов: суперполиномиальные

Продолжаем рассматривать оценки скорости алгоритмов, которые часто встречаются на практике.

С быстрыми (константные, логарифмические), «условно-быстрыми» (линейные, линейно-логарифмические) и «условно-медленными» (полиномиальными) мы разобрались. Остались только улиточки — суперполиномиальные.

Экспоненциальная сложность O(2ⁿ)

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

Если у алгоритма экспоненциальная сложность — обычно использовать на реальных задачах его нельзя. Приходится изобретать пусть не самое оптимальное, зато быстрое решение.

Типичная задача с экспоненциальным временем решения — задача о рюкзаке:

Вор пробрался на склад, где хранится N ценных штуковин. У каждого предмета своя стоимость и вес. В рюкзаке можно унести барахла не больше X кг общего веса. Какой набор штуковин выбрать вору, чтобы унести добра на максимальную сумму?

Чтобы выбрать оптимальный набор, придется сравнить все возможные комбинации предметов — их как раз 2ⁿ (все подмножества множества из n элементов).

На практике можно решить, например, «жадным» алгоритмом, который сортирует предметы по удельной ценности (стоимость / вес), и набивает рюкзак по убыванию этой ценности, пока не закончится место. Сложность становится всего-то O(n logn) — хотя решение не оптимальное, конечно.

Или если веса целочисленные, задача о рюкзаке и вовсе решается методом динамического программирования за O(nX).

Факториальная O(n!)

Время выполнения растет как факториал от n (n! = n × (n-1) × (n-2) × ... × 1). Это жесть! Если алгоритм факториальной сложности — вы не сможете использовать его даже на наборе из 20 элементов.

Типичная задача с факториальным временем решения — задача коммивояжера:

Рьяному менеджеру по продажам нужно объехать N городов в разных точках страны. Как найти самый короткий маршрут, чтобы заехать в каждый город хотя бы раз и в итоге вернуться домой?

Выбрать первый город можно n способами, второй n-1, третий n-2, и так далее — так и получается n! маршрутов, среди которых приходится искать оптимальный.

На практике можно решать методом динамического программирования, который дает экспоненциальную сложность O(2ⁿn²). Тоже не вариант для больших n, но значительно быстрее факториального алгоритма.

Или забыть об оптимальном решении и считать жадным алгоритмом, который всегда выбирает ближайший город (O(n²)).

Окончание следует ツ Если что непонятно — спрашивайте в комментариях.

#код
Сложность алгоритмов: итоги

Вот какие алгоритмы мы рассмотрели:

Константные O(1)
— Логарифмические O(log n)
— Линейные O(n)
— Линейно-логарифмические O(n log n)
Квадратичные O(n²)
— Полиномиальные O(nᵏ)
Экспоненциальные O(2ⁿ)
— Факториальные O(n!)

O(1) — всегда лучший вариант, а O(log n) — почти всегда.

С полиномиальными сложнее — тут все зависит от задачи. Где-то стыдно выбирать O(n), а где-то и O(n²) будет большим успехом.

O(2ⁿ) и O(n!) не работают на больших n, поэтому на практике вместо них часто используют неоптимальные, но быстрые алгоритмы.

А еще с помощью «big-O» можно измерять потребление памяти алгоритмом. Там тоже много интересного.

Константного времени вашим алгоритмам! Ну или логарифмического ツ

#код
Кстати! Если разбор алгоритмов показался вам суховатым или занудным, у меня есть короткое объяснение на котиках и с картинками 🐈
https://antonz.ru/big-o/

#код
Прогнозируем будущее по настоящему

Поговорим о простом, но действенном способе предсказывать будущее.

Основан он на цепях Маркова. Марков — великий русский ясновидец и экстрасенс. Враги приковали его цепями к стене и пытали, выведывая будущее. Отсюда название.

Если вы обалдели от такого начала, то не зря. Я бессовестно соврал. Андрей Марков был математиком, и никто его не пытал.

Но цепи Маркова действительно предсказывают будущее! Причем очень по-человечески — смело и наивно.

Например, я посмотрел архив погоды за 10 лет, и выяснил, что если в какой-то день Д было +5°, то и на следующий (Д+1) чаще всего тоже. Поэтому я теперь считаю, что если сегодня +5° — завтра наверняка тоже +5°. При этом что там было вчера и позавчера, я игнорирую.

Цепь Маркова работает ровно так. Вероятность очередного события в ней зависит только от предыдущего события, но не от прошлых.

Например, предиктивный ввод. Это когда вы пишете «привет», а айфон предлагает сразу добавить «как» и «как дела». У айфона внутри нейросеть, но цепи Маркова тоже так умеют.

Если же не предлагать варианты слов человеку, а выбирать автоматически — получится генератор текста. Саша Беспоясов недавно как раз написал классный разбор такого генератора на JS, а теперь мы добавили примеры и на Python.

Посмотрите:
https://bespoyasov.ru/blog/text-generation-with-markov-chains/

#код
Храним состояние в URL

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

Например, вы продаете через интернет элитный картофель. Покупатель заходит, настраивает фильтры поиска, видит список из 300 позиций.

Переходит на третью страницу, открывает карточку картофелины и случайно нажимает на рефреш страницы. Как поступит ваше приложение?

Я знаю такие варианты:

— Не хранить состояние вообще
— Хранить состояние локально
— Хранить набор URL-параметров
— Хранить сериализованное состояние
— Гибридные подходы

Вот особенности каждого

#код
Как передать состояние в URL-параметрах

На первый взгляд, передавать состояние в виде набора параметров URL легко и приятно.

Строку или число — элементарно:

{
"search": "potatoes",
"page": 5
}


→ ?search=potatoes&page=5


Логическое значение — не сильно сложнее:

{ "popular": true }


→ popular=true
popular=1


Дата и время обычно использует RFC 3339 (2020-01-02T10:11:12Z) или unix time (секунды после полуночи 01.01.1970), реже — миллисекунды:

{ "after": "2020-01-02" }


→ after=2020-01-02
after=1577923200


Неизвестное значение часто передают как пустое или не передают вовсе, реже используют маркерное значение:

{ "country": null }


→ country=null
country=
<empty>


Список сложнее. Классический вариант — повторять название свойства для каждого значения, как диктует RFC 6570.

Иногда добавляют [], чтобы подчеркнуть, что это список, бывает что и с индексами:

{
"country": ["bo", "za"]
}


→ country=bo&country=za
country[]=bo&country[]=za
country[0]=bo&country[1]=za


Фанаты краткости передают название свойства один раз, а значения разделяют запятыми.

→ country=bo,za


Словарь содержит пары ключ-значение. Чаще всего название основного свойства дублируют, а названия вложенных указывают в []. Реже используют точку.

{
"size": {
"min": 3,
"max": 7
}
}


→ size[min]=3&size[max]=7
size.min=3&size.max=7


Вложенность больше 2 уровней обычно не используют.

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

#код