Магический питон

Safoyeth 11 Мар 2017

Есть такая замечательная штука в питоне — магические методы. Они чудно́ выглядят и мало кто знает зачем они вообще нужны. Есть крайне полезная статья на Хабре, но вот примеров реального использования я как-то не встречал. Поэтому и решил наваять свой.

Итак, раз уж речь идёт о магии, то давайте впустим в нашу статью немного фантастики. Представим, что мы летим на космическом корабле куда-то в другую галактику. После многих гиперпрыжков мы оказываемся в системе, где находим обитаемую планету с достаточно развитой цивилизацией. Как выяснилось, планету местные жители (а на планете существует только одно государство) называют Атласта (ну вот так мне захотелось), сами себя они называют атлассианами (ну а почему бы нет). Ещё до установления первого контакта мы выяснили, что Атласта совершает полный оборот вокруг местного солнца, за четыреста суток, двигаясь по крайне вытянутой эллиптической орбите. Угол наклона к плоскости эклиптики составляет 0 градусов, также как и у Земли. Мы смогли установить контакт с атлассианями и начали создавать словарь. Оказалось, что атлассианский год действительно состоит из четырёхсот дней, у них отсутствуют времена года в нашем понимании, вместо этого они делят год на десять месяцев (мы для собственного удобства стали считать, что 3 месяца длится лето, 2 — осень, 3 — зима и 2 — весна). Каждый месяц состоит у них из четырёх декад, каждая декада — из десяти дней.

Что есть жизнь без дат? Вопрос философский. Для дальнейшего развития отношений нам необходимо понимать даты атлассиан, их календарь, равно как и им необходимо понимать наш. Было бы наивно полагать, что люди за тысячи световых лет от нас знают что-то о Григорианском календаре. И вот тут на сцену выходим мы! 🙂 Наша задача написать что-то (представим, что создаётся огромная программа для культурного обмена) для манипуляций с атлассианскими датами. Нам повезло, что мы не атлассиане, их программистам будет куда сложней с нашим календарём. Одно только объяснение, почему восстание Спартака было с 73 года по 71, а не наоборот уже способно нарушить их понимание арифметики 😛

Нам легче. Атлассианский Календарь делится на Эры. Первая Эра длилась примерно сто тысяч лет, никто точно не знает сколько, хотя в исторических хрониках она упоминается (выкинуть её не получится). Первая Эра называется Эра Неизвестности. Вторая Эра — Эра Тьмы. Так атлассиане называют длительный отрезок времени, примерно от становления первых империй до Великого Объединения, когда Великая Империя Караман (они её называют просто Империя) смогла победить всех своих соперников и установила власть над всей планетой. Точную длительность Эры Тьмы определить также не представляется возможным, однако она занимает существенный пласт в истории Атласты. Третья Эра — Эра Счастья — текущая. Началась она семь тысяч четыреста десять лет назад. Характеризуется полным отсутствием каких-либо конфликтов, развитием культуры и технологии. Когда мы только вступили в контакт, атлассиане показывали нам такой шифр — [3.2.8.7410.3]. Оказалось, что именно так у них принято писать текущую дату — «третий день второй декады восьмого месяца семь тысяч четыреста десятого года третьей эры». Также жители Атласты верят, что всё имеет начало и конец, поэтому они убеждены, что их цивилизация рано или поздно исчезнет. У них есть несколько религиозно-философских течений (мы пока их плохо понимаем), некоторые из них считают, что атлассиане выродятся, некоторые — что они перейдут на новый виток развития, но все сходятся в одном — та цивилизация, что есть сейчас и в том виде, что есть сейчас умрёт. Именно поэтому у них есть четвёртая эра — Эра Смерти. В силу того, что существуют мистическо-философские трактаты о Четвёртой Эре, её тоже нужно учитывать.

Что ж, есть четыре эры, есть годы. В каждом году — десять месяцев. Каждый месяц имеет своё название. Первый месяц года называется Месяцем Цветеня. В силу особенностей климата и местной растительности именно в этот месяц идёт самое бурное цветение всего на планете. Атласта — чудна́я планета. В каждой климатической зоне есть свои растения и они все как по команде начинают цветение именно в первый месяц года. Второй месяц мы примерно смогли перевести как Месяц Пылевика. Это второй месяц лета (хотя атлассиане и не понимают этого слова). Третий месяц — Месяц Жареня. Это самый жаркий месяц в году. В силу того, что всё население Атласты сосредоточено в северо-восточном четвертьшарии планеты (остальную часть занимает Великий Океан), здесь не может быть зимы в летний месяц, как это бывает на Земле. Четвёртый месяц — Месяц Листопада, пятый — Месяц Осеннего Дождепада. У атлассиан есть два одинаковых месяца — Дождепада, один — пятый, второй — десятый. Для того, чтобы не путаться в них, наши лингвисты решили их назвать Осенним и Весенним соответственно. Шестой, седьмой и восьмой месяцы мы объединили в зимние, они называются — Месяц Серебрянки, Месяц Снегопада и Месяц Стуженя соответственно. Последние два месяца — Месяц Капеля и Месяц Весеннего Дождепада. Сейчас мы не будем вдаваться в подробности о климате Атласты, для манипуляций с датами он не нужен, но общее понимание, почему месяцы у них называются именно так мы получили.

Хорошо, дальше. Каждый месяц состоит из сорока дней, которые объединяются в декады. На Атласте нет той жути, которая есть на Земле с месяцами (то тридцать дней, то тридцать один, то вообще двадцать восемь, а иногда ещё и двадцать девять!), у них все месяца чётко расписаны по сорок дней. Соответственно в месяце четыре декады, каждая из которых тоже имеет имя. Декады называются достаточно поэтично и соответствуют мировоззрениям атлассиан о начале и конце всего — Декада Юности (первая), Декада Молодости (вторая), Декада Зрелости (третья) и Декада Старости (четвёртая).

Каждая декада состоит из десяти дней, которые в отличие от земных не имеют имён. У них нет понедельников и пятниц, только Первый День Декады и Пятый день Декады соответственно. Каждая декада начинается с Праздника Встречи. Праздник Встречи — это выходной день на всей планете (конечно, есть люди, которые вынуждены работать, как и на Земле). Каждый Праздник Встречи тоже довольно поэтичен в названии — например Праздник Встречи Молодости или Праздник Встречи Зрелости. В этих названиях есть какое-то очарование… Каждая декада также заканчивается праздником, который называется Праздник Прощания. Согласитесь, «Праздник Прощания с Юностью» звучит грустно, но этим буквально пронизана вся атлассианская культура. Первый и последний дни месяца отмечаются Праздником Встречи Месяца и Праздником Прощания с Месяцем. К примеру, первый день первой декады месяца Серебрянки будет Праздником Встречи Месяца Серебрянки. Больше никаких праздников и выходных дней на Атласте нет.

Все вводные данные получены и пришло время написать наше нечто. Для начала давайте просто возьмём и напишем заготовку класса:

Подобным образом выглядят почти все классы, которые мы пишем. Мы наследуемся от чего-то, в данном случае от object, а дальше начинается та самая магия. Первый магический метод, который все всегда используют (и почти никогда мы даже и не задумываемся о «магии») — __init__. Таким образом мы инициализируем наш класс и передаём в него аргументы. Для нашего класса Date мы передадим аргументы day, decade, month, year и era=3 для дня, декады, месяца, года и эры соответственно. Сразу примем за умолчание, что мы находимся в Третьей Эре. Строка super(Date, self).__init__() — это инициализация суперкласса. Пока просто примем её как данность. Теперь мы можем создавать экземпляры нашего класса. Мы прилетели на Атласту [3.2.8.7410.3], можно создать эту дату так: dateWhenWeCome = Date(3, 2, 8, 7410, 3) или, помня, что эра у нас предопределена так: dateWhenWeCome = Date(3, 2, 8, 7410). Однако, если мы попробуем распечатать дату, то получим нечто такое: <__main__.Date object at 0x0000000002BB4438>. Это не то, что хотели бы от нас в нашей супер программе для культурного обмена 🙂 поэтому давайте магичить.

Первое, что нам нужно сделать — превратить набор цифр, с помощью которых мы создаём дату, в человекопонятный текст. То есть дата [3.2.8.7410.3] должна превращаться в «3 День Декады Молодости Месяца Стуженя 7410 Года Эры Счастья». Да, именно так атлассиане говорят! Делается это элементарно с помощью магического метода __str__ (python 3.x) или __unicode__ (python 2.x). Поскольку наши космические корабли бороздят просторы Вселенной, мы, конечно, пишем на третьем питоне, поэтому сделаем так:

То есть мы просто превращаем цифры во фразы, а затем строим результирующую строку. Поскольку в будущем мы планируем манипулировать датами, мы знаем, что у нас будут манипуляции в пределах одной эры, поэтому она нам не понадобится. Именно поэтому за преобразование эры отвечает такая строка era(self.era) if self.era else "". Конструкция return {1: "Что-то"}[i] для питона выглядит диковато, но это такая хитрая замена switch-case. При желании можно всё переписать через if-elif-else.

Теперь если выполнить print(dateWhenWeCome), то мы получим понятную человеку строку: «3 День Декады Молодости Месяца Стуженя 7410 Года Эры Счастья». Первая магия сработала!

Следующая задача. Отображение в качестве длинной строки — это, конечно, показывает наше уважение к жителям Атласты и [прочая политкорректная чушь], но атлассиане также как и земляне пользуются не одним форматом даты. Мы, к примеру, можем написать дату так: 11.03.17 или 11.03.2017 или 11 марта 2017 года и много как ещё. К счастью для нас атлассиане кроме длинного формата, который мы научились представлять с помощью метода __str__ используют только один альтернативный способ — [3.2.8.7410.3]. То есть [День.Декада.Месяц.Год.Эра]. Всё строго в квадратных скобках и через точку. Что ж давайте, реализуем и такое отображение. Есть такая магическая штука __repr__, ею мы и воспользуемся.

Как магический метод __str__ определяет поведение функции str(), вызванной на экземпляре класса, так и метод __repr__ определяет поведение функции repr(). repr() — это как бы отображение класса в самом питоне. Делаем магию:

Тут даже и добавить нечего. Магические методы почти всегда элементарны и позволяют делать всякое разное полезное. Теперь, если мы вызовем что-то типа print(repr(dateWhenWeCome)) или print(dateWhenWeCome.__repr__()), результат будет ожидаемым — [3.2.8.7410.3].

Правила хорошего тона говорят нам, что использование в одном случае str(), в другом repr() не сказать, что очень удобно. Давайте позаботимся о наших коллегах, которые пилят другие модули нашей супер программы по культурному обмену и унифицируем вывод. Проще всего это сделать с помощью функции с говорящим именем, например toString(). Напишем её:

Мы написали две строчки кода, но наши коллеги будут нам благодарны, теперь они могут манипулировать отображением даты в обоих форматах.

Наша следующая задача не относится к магическим методам, но она полезна. Мы умеем создавать дату, мы научились отображать дату в обоих форматах, которые используют атлассиане, но мы не умеем превращать строку в дату, а это крайне полезно. К примеру в Qt есть метод fromString(). Нам бы тоже хотелось вкусностей. К сожалению в питоне нельзя перегружать конструктор класса, поэтому мы не можем просто написать ещё один конструктор, как я бы сделал в С++. Зато есть @classmethod, позволяющий написать метод класса, который будет очень поход на статический метод fromString() из Qt. Просто чтобы не загромождать код, давайте договоримся, что наш метод, позволяющий создать дату из строки, будет работать только с датой в формате [День.Декада.Месяц.Год.Эра]. В противном случае, придётся писать код парсинга длинной строки (аналогичный __str__). Собственно, метод:

Работает всё просто. Мы получаем на вход строку, отбрасываем скобки и превращаем её в список значений. На этом этапе мы должны получить список из пяти символов (день, декада, месяц, год, эра). Если это не так, то мы поднимаем ValueError, очевидно, что наша строка — не дата в коротком атлассианском формате. Если же всё нормально, то мы возвращаем класс, в который передаём наши значения. Понятно, что проверок нужно больше и метод должен быть сложнее и изощрённей, но в данный момент мы оставим всё так (просто экономии время ради). Теперь мы можем использовать его как-то так print(Date.fromString("[1.2.3.2.3]")) для полной даты или даже print(Date.fromString("[1.2.3.2.3]").toString(False)) для того, чтобы распечатать короткую дату. Главное, что мы создаём новый экземпляр класса Date из строки с помощью метода класса. То есть date, определённая таким образом date = Date.fromString("[2.3.4.7765.2]") будет экземпляром класса Date.

Отлично! Мы научились простейшим трюкам с датами, но всё это никак не помогает нашей миссии по культурному обмену. Что мы ещё можем делать с датами, кроме их отображения? Правильно, мы можем прибавлять, отнимать дни, недели, месяцы и годы, сравнивать даты, вычитать из одной даты другую. То есть, мы можем (не всегда, конечно, в уме) проводить с датами некоторые арифметические операции. Например, любой из нас сможет сказать, какая будет дата, если к текущей (11.03.2017) прибавить одну неделю. И конечно мы знаем какая дата раньше — 11.03.2017 или 26.11.1960. В нашем модуле нам нужно что-то такое же!

Как несложно догадаться арифметические операции не проводятся со строками, они проводятся с цифрами. Операции сравнения тоже проще всего проводить с цифрами, а значит мы должны научиться представлять атлассианскую дату как цифру. Сделать это нам несложно (а вот они с нашими датами помучаются!) и воспользуемся мы очередным магическим методом __int__:

Как и большинство магических методов, __int__ отвечает за поведение одноимённой функции и он элементарен! Мы просто раскладываем текущую дату на число дней с начала эры. Тут есть один момент. Мы вообще никак не учитываем эру в данном методе. Для того, чтобы эра учитывалась, можно использовать комплексные числа, соответственно сразу напишем второй магический метод __complex__:

Он совсем элементарен. Идея хранить дату как число дней (int) однозначно здравая, а вот с комплексными числами могут возникнуть вопросы. Но, во-первых мы же тренируемся с магическими методами, а во-вторых, это нам реально понадобится. Можно попробовать посчитать число дней с начала эры: int(dateWhenWeCome) или complex(dateWhenWeCome), получим 2963893 и 2963893+3j соответственно.

Раз мы научились преобразовывать дату в числа, то рано или поздно нам потребуются и обратные преобразования. Снова в деле наши методы класса! Сначала для комплексных чисел:

Это, пожалуй, единственный сложный метод, если можно так сказать. Смысл же в следующем: мы берём реальную часть комплексного числа, целочисленно делим 400 и получаем количество лет с начала эры, добавляем 1 год, если же число делится на 400 без остатка, то ничего не добавляем (или если добавляли раньше, то отнимаем 1 год), проводим примерно все те же операции с месяцами, декадами и днями. В конце возвращаем новый класс, куда просто передаём полученные значения дня, декады, месяца и года, а также мнимую часть от нашего комплексного числа. Можно элементарно проверить правильность таким образом: print(Date.fromComplex(2963893+3j), dateWhenWeCome) и мы получим одну и ту же дату. Для обычных чисел всё вообще элементарно:

Мы просто пользуемся тем фактом, что стандартная функция complex определена как complex(real, imag=None).

Теперь мы заимели много всякого полезного, в частности, мы можем конвертировать атлассианские даты в строки, в числа и получать их из строк и чисел. Впереди самое вкусное — сравнение и арифметические операции, но сначала давайте добавим полезных методов нашему классу. Дело тут вот в чём: вряд ли нашим коллегам, которые будут разрабатывать другие части нашей супер программы, понадобится конвертировать даты в числа, поэтому красивые методы toInt() или toComplex() мы писать не будем. Зато им наверняка понадобятся другие методы, например количество дней с начала эры, количество недель с начала эры, количество декад и/или месяцев с начала эры, ну и всякие там проверки на то, рабочий ли день. Поэтому давайте уважим наших коллег сначала этими методами.

Начнём с daysSinceStartOfEra() и currentDaySinceStartOfEra — первый метод будет выдавать количество дней, прошедших с начала эры, второй — номер текущего дня с начала эры. Первый метод мы сделаем статическим, второй будет свойством:

По сути оба метода это надстройки над магическим __int__, переведённые в понятные для человека названия методов. Далее аналогичные методы для декад, месяцев и лет:

Да, их не так интересно реализовывать, но польза от них вполне может быть несопоставимой со сложностью их реализации. Теперь ещё несколько полезных методов:

Они тоже просты. isWorkingDay проверяет является ли день рабочим. Как мы помним на Атласте рабочим днём является любой, кроме первого и последнего дня каждой декады, поэтому проверка совсем детская. isDecadeStartCelebration и isDecadeEndCelebration проверяют какой именно праздник в данный момент — Праздник Встречи Декады или Праздник Прощания с Декадой. Аналогично работают и методы для праздников Встреч и Прощаний с месяцами. Все методы для удобства статические.

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

  • __eq__ — равенство (==) equal — так можно запомнить его сигнатуру
  • __ne__ — неравенство (!=) not equal — так можно запомнить его сигнатуру
  • __lt__ — меньше (<) less than — так можно запомнить его сигнатуру
  • __gt__ — больше (>) greater than — так можно запомнить его сигнатуру
  • __le__ — меньше или равно (<=) less than or equal — так можно запомнить его сигнатуру
  • __ge__ — больше или равно (>=) greater than or equal — так можно запомнить его сигнатуру

Теперь о реализации. Я большой поклонник языка Haskell и именно оттуда мне пришло понимание всей магии, что здесь происходит, поэтому и реализовывать мы будем часть из них в стиле Haskell — одно через другое. Начнём с равенства и неравенства:

Да! В одну строчку! Фокус в том, что для комплексных чисел существует проверка на равенство (метод __eq__ реализован), а мы уже позаботились о том, чтобы наши даты могли вести себя как комплексные (и не только!) числа. И да, неравенство мы выражаем как «не равенство». Поздравляю, теперь мы можем сравнивать даты! Просто взять и написать Date(1, 2, 2, 4, 3) == Date(1, 2, 2, 3, 3) и получим адекватный ответ!

Теперь больше-меньше. Также изящно не получится, так как если одна дата «не больше», то она не всегда меньше (они могут быть равны). Кроме того, комплексные числа нельзя сравнивать в лоб (как мы делали это на равенство) на больше-меньше, так как мнимая часть на то и мнимая. Но об этом мы тоже позаботились, у нас есть метод int()! Для примера я записал реализацию «меньше» инлайновым выражением, а «больше» обычным (просто для наглядности):

И вновь всё красиво и понятно (ведь понятно же, что если одна дата из третьей эры, а вторая из второй, то первая дата больше, то есть позже…), а мы теперь можем записать что-то такое: Date(1, 2, 2, 4, 3) < Date(1, 2, 2, 3, 3) и получим правильный ответ!

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

Супер! Теперь мы можем сравнивать даты как угодно, хоть так: Date(1, 2, 2, 4, 3) <= Date(1, 2, 2, 3, 3). Кстати, можно было и в методе не использовать магические вызовы, а написать просто таким образом return True if self < other or self == other else False. Так меньше писанины, понятней. Я оставил вызов магических методов просто для того, чтобы показать, что их можно легко вызывать в нашем классе.

Итак, мы научились крутым магическим штукам с датами, написали много всяких методов для наших коллег, реализовали красивые методы для сравнения дат, но не хватает главного — мы не умеем считать. Или, говоря более обобщённо, мы не умеем перемещаться в плоскости дат. Это мы сейчас и исправим и поможет нам в том следующая россыпь магических методов:

  • __add__ — сложение (self + other) add — придётся просто запомнить.
  • __radd__ — отражённое сложение (other + self) reflected add — как-то так
  • __sub__ — вычитание (self - other) subtract — легко запомнить.
  • __iadd__ — сложение с присваиванием (self += other) Надо просто запомнить сигнатуру.
  • __isub__ — вычитание с присваиванием (self -= other) Надо просто запомнить сигнатуру.

Прежде, чем мы начнём реализовывать эти методы, нужно кое-что прояснить. В нашем случае умножение и деление не применимы (пока ещё в моём воспалённом мозгу не родился мир, где люди зачем-то умножают 12 декабря 2000 года на пять 🙂 ). Мы не будем складывать сами даты, хотя мы можем (не очень понимаю смысл выражения 21 января 1958 года плюс 22 января 1958 года)… И вычитать даты мы тоже не будем (для этого мы запилим собственный метод). Дальше пара слов о сложении. Сложение, как известно, операция бинарная, то есть от перемены мест слагаемых ну вы помните. Именно поэтому для сложения мы реализуем отражённую версию (то есть можно будет написать Date + 77 и 77 + Date). Вычитание, напротив, операция унарная, поэтому Date - 50 — это нормально, а вот 50 - Date — это, извините, чушь…

Ну а теперь магия:

Начнём со сложения: мы просто превращаем нашу дату в комплексное число, прибавляем к нему другое число, а затем нашим методом fromComplex() обратно конвертируем число в дату. С отражённой операцией всё ещё проще — мы просто вызываем вышеописанный метод (для разнообразия через плюс, а не через self.__add__(other)). Про вычитание с присвоением даже особо и сказать нечего… Вычитание чуть-чуть интереснее. Мы не знаем (так как атлассиане сами не знают) верхнюю границу дат, то есть они (и мы тоже) не знают когда именно кончилась Первая Эра, или Вторая Эра. Поскольку сейчас Третья Эра, то когда она кончится также не известно. Поэтому мы можем складывать без каких-либо ограничений (конечно, следует ограничить рамки зон примерными интервалами, но нас сейчас это не интересует), зато мы точно знаем, что каждая эра начинается [1.1.1.1], то есть в 1 день Декады Юности Месяца Цветеня 1 года. А значит, при вычитании мы легко можем выскочить за границы эры. Именно поэтому наше вычитание и, следовательно, вычитание с присвоением реализовано таким вот чудаковатым способом.

Вот такая магия. Мы можем делать с датами всё, что нам только взбредёт в голову! Но давайте в последний раз вспомним о коллегах (мы же ответственны!). Сложение и вычитание — это здорово, но хотелось бы (не нам, им! нас-то всё устраивает) ещё пару вещей. Хотелось бы им возможность добавлять не только дни, но и декады/месяцы/годы (ну прям как в Qt addDays/addMonths/addYears), ну и конечно они хотят знать сколько прошло времени (в днях) между нашим первым контактом (который напомню состоялся на 3 День Декады Молодости Месяца Стуженя 7410 Года Эры Счастья [3.2.8.7410.3]) и коронацией её Величества Королевы Йоллии — правительницы Атласты (которая состоялась [7.1.1.7379.3]).

Начнём с простейшего:

Вычитание дней, декад, месяцев и лет также как и в Qt работает через сложение отрицательных чисел. Вычитание, оно вообще такое 🙂 Теперь вычитание дат (пусть будет статическим)

Готово! Мы можем посчитать всё, что угодно. Мы можем манипулировать датами как угодно и всё благодаря магии питона. Можно придумать ещё много всякого, цели такой у меня не было. Магических методов легион, они не серебряная пуля, но иногда (пусть редко) их использование позволяет делать достаточно сложные вещи практически элементарно. Ну а в нашей истории счастливый конец — благодаря питону с его магией земляне и атлассиане смогли наладить продуктивные отношения и одни продолжили, а другие наконец-то начали жить в мире и в том, что больше семи тысяч лет назад жители далёкой планеты Атласта назвали «Счастье»…

Так что всем добра, мира и Счастья!

Если вы нашли ошибку, пожалуйста, выделите фрагмент текста и нажмите Ctrl+Enter.




Добавить комментарий

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: