Как стать автором
Обновить

ООП в Wolfram Mathematica

Уровень сложностиСредний
Время на прочтение13 мин
Количество просмотров2.8K
Код на Wolfram Language тоже может быть своего рода ООП
Код на Wolfram Language тоже может быть своего рода ООП

В комментариях к мой статье пользователь @Refridgeratorв ответ на мой вопрос написал, что в Wolfram Language (WL) не хватает следующего:

ООП, перегрузки операторов, строгой типизации, событийно-ориентированного программирования, дата-ориентированного программирования, параллельного программирования с примитивами синхронизации, средств отладки, скорости исполнения. (с) @Refridgerator

Я отлично понимаю, что вокруг Mathematica сложились некоторые исторические стереотипы. В них обычно WL представляется как калькулятор на стероидах или просто игрушка, или больше язык запросов, которым можно дополнительно решать уравнения и строить графики. Сегодня я попытаюсь показать, что в языке есть не только лишь графики, уравнения и интегралы. Вряд ли у меня хватит сил написать подробно касательно каждого пункта списка, но я постараюсь объяснить хотя бы часть.

TLDR

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

Изменяемые объекты

Как все мы прекрасно знаем WL следует принципу неизменяемых объектов. Единственный способ изменить выражение (а не создать целиком новое) это использовать функцию Part и заменить часть выражение, которое хранится в собственных значениях символа. Если сказать человеческим языком - то мы просто можем создать переменную, которая хранит сложный объект, и уже этот объект менять. Это довольно сильное отличает WL от таких языков как Java, C#, Python и JavaScript накладывает свои ограничения, но имеет свои плюсы. Мне в принципе нравится эта концепция, но всегда хотелось иметь возможность писать код чем-то похожий на тот, что можно написать на Python. Не так давно изменяемые объекты были добавлены в язык, но они настолько неудобны, что я ими очень мало пользуюсь (да и свои объекты я сделал раньше). Изменять голые символы напрямую тоже неудобно. Ну и по итогу у меня созрела идея реализовать свои собственные объекты - их-то я сейчас и покажу, а потом расскажу, как они работают.

Во-первых, нужно иметь одну из последних версий WL. Далее нужно установить пакет из Paclet Repository, который называется довольно незатейливо - Objects:

PacletInstall["KirillBelov/Objects"]

И сразу импортируем его:

Get["KirillBelov`Objects`"]

После импорта нам доступны следующие функции:

?KirillBelov`Objects`*
Всего в пакете 4 публичные функции
Всего в пакете 4 публичные функции
  • CreateType - создает изменяемый тип

  • Object - самый примитивный изменяемый объект

  • ObjectQ - функция, которая проверяет является ли выражение изменяемым объектом

  • TypeQ - функция, которая проверяет является ли выражение изменяемым типом

Начнем с базового типа для всех изменяемых объектов. Чтобы создать объект нужно просто вызвать "конструктор":

obj1 = Object[]
Объект отображается в блокноте в виде SummaryBox со списком свойств
Объект отображается в блокноте в виде SummaryBox со списком свойств

На самом деле это просто функция, которая возвращает объект с тем же самым заголовком, но при этом в нем появляется внутренняя структура. В этом плане я сделал нечто похожее на объекты в JavaScript, где точно также конструктором является функция, которая возвращает object. Обратите внимание, что в напечатанном в выходной ячейке объекте перечислены его свойства. Я предусмотрел возможность задать этим свойствам конкретные значения в конструкторе при помощи синтаксиса опций, который работает как и в большинстве функций Wolfram Language:

obj2 = Object["Icon" -> Graphics3D[Sphere[], Boxed -> False]]
Указав свойство Icon мы изменили картинку в левой части SummaryBox
Указав свойство Icon мы изменили картинку в левой части SummaryBox

По умолчанию все свойства объектов имеют значение Automatic, если для них не было задано значение при определении самого типа.

Инкапсуляция

Один из принципов, который позволяет реализовать ООП. Здесь я должен отметить, что я не следовал строго всем принципам. В первую очередь я смотрел на то, как что-то работает в других языках, таких как Python, Java, C# и JavaScript и переносил это на изменяемые объекты в том виде, в каком лично мне показалось наиболее удобным для WL. Поэтому здесь не будет классической инкапсуляции как в Java и C#, а будет неполноценная инкапсуляция без сокрытия, т.е. такая как в Python. Ведь все объекты на самом деле это выражения, а мы можем получить доступ к любой части любого выражения без каких-либо проблем. Поэтому объекты содержат свойства, но их нельзя сделать приватными. С другой стороны такому языку как Python это не мешает. Пусть сокрытие и не работает, но главная цель инкапсуляции выполнена - теперь все свойства объекта заключены внутри него самого и доступ к их значениям можно получить только непосредственно через объект. Таким образом мы можем строить сложные объекты, а на более высоких уровнях выстраивать их взаимодействие не задумываясь над тем, как они реализованы внутри. Как же работать со свойствами?

Вот так можно свойство извлечь:

obj1["Icon"]
Иконка для SummaryBox является свойством объекта
Иконка для SummaryBox является свойством объекта

Вот так свойство можно изменить:

obj1["Icon"] = ColorNegate[obj1["Icon"]]
obj1
Я просто обратил цвета иконки
Я просто обратил цвета иконки

Можно добавить новое свойство так, как это делается в JavaScript:

obj1["Name"] = "Object 1"
obj1
В списке свойств появилось "Имя"
В списке свойств появилось "Имя"

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

obj1 @ Name
obj1 @ Name = "New name for the object"
obj1 @ Name
obj1 @ NewProperty = {1, 2, 3}
obj1
Изменяем имя и добавляем новое свойство
Изменяем имя и добавляем новое свойство

На самом деле все то, что я показал выше уже очень подозрительно похоже на Association - встроенную структуру данных в WL и аналог dict из Python. Так и есть - объекты действительно работают с использованием этой структуры, но ассоциация сама по себе является неизменяемой и всегда копируется целиком. А объекты изменяемые и можно создать много ссылок на один объект и мутировать его из разных мест. Ниже наглядная демонстрация различий:

obj1 = Object[]; 
obj2 = obj1; 
obj1["Icon"] = ColorSeparate[obj1["Icon"], "L"]; 
{obj1, obj2}
Иконка изменилась сразу у обоих объектов
Иконка изменилась сразу у обоих объектов

Если я попытаюсь проделать все тоже самое с ассоциациями:

assoc1 = <|"Name" -> "Assoc1"|>; 
assoc2 = assoc1; 
assoc1["Name"] = "New Assoc1"; 
{assoc1, assoc2}
Переменная assoc2 не изменилась
Переменная assoc2 не изменилась

То переменная, в которую была скопирована ассоциация никак не изменится. Я ссылаюсь на Python и вот как подобный код будет работать в нем:

dict1 = {"Name": "Dict 1"}; 
dict2 = dict1; 
dict1["Name"] = "New Dict 1"; 
print([dict1, dict2])
Имя в словаре изменилось для всех переменных ссылающихся на него
Имя в словаре изменилось для всех переменных ссылающихся на него

Более того, мне даже не нужно создавать "ссылку" на объект чтобы работать с ним. Я могу просто создать объект в ячейке и не сохранять его в переменную вот так:

Object[]
Объект никуда не сохранился, но сборщик мусора его не трогает
Объект никуда не сохранился, но сборщик мусора его не трогает

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

Копируем
Копируем
Меняем иконку используя SummaryBox напрямую
Меняем иконку используя SummaryBox напрямую

Не смотря на то, что я все еще могу копировать SummaryBox, где иконка красная - это уже измененный объект. Просто его отображение в UI Mathematica статическое и скопировав в буфер обмена формочку SummaryBox с красной иконкой - такой она и останется, а вот объект в памяти - нет. Ведь SummaryBox - это просто отображение в блокноте, а за ширмой на самом деле прячется всего лишь выражение вот такого вида:

Object[KirillBelov`Objects`Object`$15]
Реальный вид объекта и то, как он хранится в памяти
Реальный вид объекта и то, как он хранится в памяти

Еще одним важным свойством свойств (простите за каламбур) является то, что их можно задавать с отложенным выполнением. По сути это аналог геттеров из Java или C#. Вот как это можно сделать:

obj1["Random"] := RandomReal[]
{obj1["Random"], obj1["Random"], obj1["Random"]}
Свойство, которое возвращает случайное число
Свойство, которое возвращает случайное число

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

Наследование

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

(* CreateType[type, parent, init, {fields}] *)

CreateType[Human, Object, Identity, 
  {"Name", Age, "Country" -> Entity["Country", "Russia"]}]

Kirill = Human[]
Создаем новый тип, который наследуется от Object
Создаем новый тип, который наследуется от Object

Обратите внимание, что в списке "Properties" появились как имена свойств из Object, так и новые, которые мы только что задали типу. Т.е. свойства были наследованы!

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

  • parent - по умолчанию наследование от Object

  • init - по умолчанию это Identity - по сути функция ничего не делает

  • fields - по умолчанию пустой список {}

Стоит отменить, что родительский тип обязан быть уже существующим типом, а свойства указываются в виде списка правил или списка имен. Там где вместо имени правило (как для "Country"), то правая часть будет использована по умолчанию. Там где только имя (строка или символ) - по умолчанию значения свойства будет Automatic. Вообще странно, что я создал тип "человек", но у него осталась старая иконка с рисунком Спайки. Можно это как-то изменить? Конечно! Для любого свойства родительского типа можно переопределить значение по умолчанию вот так:

CreateType[Human, {
    "Name", 
    Age, 
    "Country" -> Entity["Country", "Russia"], 
    "Icon" -> Style["?‍♂️", 22]
}]

Kirill = Human["Name" -> "Kirill Belov"]
Теперь в качестве иконки по умолчанию используется символ человека из юникода
Теперь в качестве иконки по умолчанию используется символ человека из юникода

С наследованием свойств мы разобрались. Конечно, там есть еще тонкости, но перейдем к более важным вопросам. А как же методы? Где они вообще? Ведь про них пока что не было ни слова! Вот сейчас-то мы до них и добрались. Сначала немного пространных рассуждений.

В Wolfram Language существует несколько способов создать и сохранить определение. От способа зависит во первых место, куда определение будет сохранено, а во вторых символ, с которым определение будет связано. Когда мы создаем стандартную функцию вот так:

func1[x_, y_] := x^2 + y^3 - 1

То определение функции связывается с именем func1. Теперь оно будет хранится в DownValues для этого символа:

DownValues[func1]
Как хранится определение функции
Как хранится определение функции

И естественно мы можем создать функцию, которая определена на нашем объекте и изменяет его каким-то образом. Допустим, мы хотим, чтобы созданный нами человек умел что-то говорить, например, слово "привет" и называть свое имя. Вот такую функцию можно для этого создать:

sayHi[human_Human] := Echo[
  StringTemplate["Hi! My name is `1`"][human["Name"]], 
  Row[{human["Name"], " says: "}]
]

sayHi[Kirill]
Echo печатает и возвращает результат, поэтому строка приветствия в Output попала два раза
Echo печатает и возвращает результат, поэтому строка приветствия в Output попала два раза

Обратите внимание, что я указал шаблон human_Human, где после знака подчеркивания идет заголовок выражения. Это позволяет точно указать тип, на котором будет работать функция. С любым другим типом она просто не будет работать и вернет обратно весь Input. Таким образом мы можем использовать сильную типизацию в аргументах функций, т.е. такую типизацию, где отсутствует неявное приведение типов. Это значит, что наша функция будет работать на типе Human и только на нем. А как же наследование? Давайте создадим производный тип, например студента и попробуем вызвать ту же самую функцию на нем:

CreateType[Student, Human, {"Course" -> 1}]

Ivan = Student["Name" -> "Ivan"]

sayHi[Ivan]
Вернулось выражение в неизменном виде
Вернулось выражение в неизменном виде

Как видно выше, если даже человек умеет говорить "привет", то студент такой способностью обделен. Хотя он полностью унаследовал все свойства человека, но функции определенные на нем - нет. В чем же дело? Дело в связывании. Функция sayHi имеет глобальное определение и сильную типизацию. На самом деле тип Human вообще ничего не знает о том, какие функции были на нем определены. Это не так плохо, как кажется на первый взгляд, так как дает выбор не таскать за каждым объектом кучу определений, которые ему не нужны, а создать что-то конкретное для типа не изменяя сам тип. В общем по этому поводу можно долго рассуждать. Самое главное я показал проблему и объяснил ее. Есть ли решение? Естественно! Кроме связывания определения с функцией, мы так же можем связать его и с самим типом при помощи UpValues! Чтобы создать такое определение нужно использовать чуть-чуть другой синтаксис, где нужно указать собственно символ-тип, к которому определение нужно привязать:

ClearAll[sayHi]

Human /: sayHi[human_Human] := Echo[
  StringTemplate["Hi! My name is `1`"][human["Name"]], 
  Row[{human["Name"], " says: "}]
]

sayHi[Kirill]
Так как предыдущее определение нам не подходило - я его сначала очистил
Так как предыдущее определение нам не подходило - я его сначала очистил

Пока что все работает в точности так же. Только синтаксис создания функции (теперь это можно назвать методом) чуть-чуть изменился. В самом начале мы указали Human /: чтобы показать к чему привязывается определение. Это сработает ТОЛЬКО если определение имеет вид f[x_MyType] := .., т.е. чтобы сам объект был на первом уровне в ТЕЛЕ шаблона и только там. Т.е. вот так уже не сработает:

Human /: human_Human[] := {} (* тут Human на уровне 0 *)
Human /: f[g[human_Human[]]] := {} (* тут на уровне 2 *)

Определение созданное таким образом было привязано не к функции, а с типу Human. Вот как это можно проверить:

Cases[UpValues[Human], _[_[sayHi[_]], _]]
У Human много определений, поэтому я выбрал нужное
У Human много определений, поэтому я выбрал нужное

И теперь, если мы снова создаем тип Студент, то определение sayHi наследуется:

CreateType[Student, Human, {"Course" -> 1}]

Ivan = Student["Name" -> "Ivan"]

sayHi[Ivan]
Теперь студент стал нормальным человеком, а не немым
Теперь студент стал нормальным человеком, а не немым

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

Полиморфизм

Полиморфизм в WL отличается от того как он реализован в других языка, которые я уже неоднократно перечислял. Во-первых, если брать опять же Python, Java, C# и JavaScript, то ни один из этих языков не имеет сильной типизации. У Python и JS ее вообще нет, а Java и C# имеют слабую типизацию. При этом первая пара имеет динамическую типизацию, а вторая - статическую. WL при этом имеет сильную динамическую типизацию там, где это возможно.

Я еще раз хочу обратить внимание на то, что классификация языков программирования и обсуждение парадигм и подходом очень холиварная тема и я люблю ее ее пообсуждать и буду рад, если кто-нибудь в комментариях поделится своим мнением. Поэтому, чтобы дать дополнительную пищу для холивара, я должен уточнить, что сильная и слабая типизация в контексте статьи означает отношение функции и аргументов (ведь в WL ничего другого нет), а динамическая и статическая типизация относится к способу выделения памяти для объектов. В Java и C# это можно сделать только один раз под конкретный тип и даже var и dynamic не помогут перезаписать уже сохраненное значение на значение с другим типом. JS и Python же наоборот позволяют в любую переменную записать все что угодно. Получается в WL переменной можно в любое мгновение динамически менять тип, но при этом функции всегда проверяют тип аргументов и не делают неявное приведение.

Собственно перейдем к особенностям полиморфизма. В предыдущих разделах я говорил, что часть функционала моего пакета похожа на JS, а часть на Python и немного на C#. Мы знаем, что C# и Java легко поддерживают перегрузку методов, а Python насколько я понимаю делает это через костыли с атрибутами по итогу создавая определение с условиями. В JS перегрузки вообще нет - нужно в ручную проверять в условных операторах аргументы. Если я не прав - искренне надеюсь меня поправят.

Перегрузка

Но как работает перегрузка в WL? (Я должен уточнить, что это не является особенностью конкретно моего пакета, а язык все это умеет по умолчанию). Больше всего перегрузка функций похожа на Java и C#. Но есть одно важное отличие. Допустим я могу создать класс и два метода и одним именем, но разными параметрами:

public class Human {
  public string Name { get; set; }
  
  public string sayHi(){
    return $"Hi! My name is {Name}"; 
  }
  
  public string sayHi(string postfix){
    return $"Hi! My name is {Name}. {postfix}"; 
  }
}

new Human {Name = "Kirill Belov"}.sayHi(); 
new Human {Name = "Kirill Belov"}.sayHi("And what is your name?"); 
Пример в .Net Interactive Notebooks
Пример в .Net Interactive Notebooks

Что здесь произошло на самом деле? В таблице методов CLR для этого класса создается два определения, у которых отличаются сигнатуры - т.е. имя метода + типы и количество аргументов. Можно ли сделать что-то подобное в WL? Конечно да!

Human /: sayHi[human_Human, postfix_String] := 
 Echo[
  StringTemplate["Hi! My name is `1`. `2`"][human["Name"], postfix],
  Row[{human["Name"], " says: "}]
]

sayHi[Kirill]
sayHi[Kirill, "What is your name?"]
На скриншоте только новая перегрузка, первое определение есть выше
На скриншоте только новая перегрузка, первое определение есть выше

Получилось в точности тоже самое, что в примере с C#. Но это еще не все. Дело в том, что определения в WL не используют сигнатуры. Они используют шаблоны. А шаблоны являются целым внутренним языком. Это похоже на паттерн-матчинг из функциональных языков, только паттерны можно напрямую указывать в определении функции, а не проверять в теле.

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

Human /: addPets[human_Human, pets: {{_String, _}..}] /; 
AssociationQ[human["Pets"]] := 
Table[With[{petName = pet[[1]]}, human["Pets", petName] = pet], {pet, pets}]

Human /: addPets[human_Human, pets_] := (
  human["Pets"] = <||>; 
  addPets[human, pets]
)

addPets[Kirill, {{"Robet", Dog[]}, {"Jessica", Cat[]}}]
Kirill["Pets"]
На самом у меня нет кошки и собаки. Роберт и Джессика - мягкие игрушки моей дочери
На самом у меня нет кошки и собаки. Роберт и Джессика - мягкие игрушки моей дочери

Что мы видим выше? Поиск определения в списке происходит не просто по сигнатуре. В данном случае аргумент сначала сравнивается с шаблоном {{_String, _}.. }, который означает список списков, где на втором уровне два элемента и первый - строка. Дополнительно после это сравнения происходит проверка, что у объекта human есть свойство "Pets" и оно является ассоциацией. Такой способ создания определений намного более гибкий чем использование сигнатур. Но есть и свои минусы - поиск функций по шаблонам медленнее чем по сигнатурам. Кроме того, что мы можем расширить сигнатуру шаблоном - т.е. превратить во что-то более сложное по структуре, мы еще можем и сузить сигнатуру. Типичная сигнатура указывает, что функция принимает аргумент определенного типа, т.е. это некий диапазон возможных значений. Шаблон же может быть в том числе и одним конкретным значением. Это значит что я могу создать вот такую функцию-метод:

Human /: getName[human_Human, "first"] := 
StringSplit[human["Name"]][[1]]

Human /: getName[human_Human, "last"] := 
StringSplit[human["Name"]][[-1]]

getName[Kirill, "first"]
getName[Kirill, "second"]
getName[Kirill, "last"]
Второй вызов вернул Input как есть
Второй вызов вернул Input как есть

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

Переопределение

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

CreateType[Student, Human, {"Course" -> 1}]

Student /: getName[student_Student, "last"] := 
StringTemplate["Call me just `1`. Let's not be formal."][getName[student, "first"]]

Ivan = Student["Name" -> "Ivan"]
getName[Ivan, "first"]
getName[Ivan, "last"]
Студентам не нужны формальности
Студентам не нужны формальности

Обратите внимание, что определение для "first" сохранилось и наследовалось от Human! Даже с учетом того, что сигнатуры одинаковые и для одной перегрузки мы создали определение, а для второй нет. Все тоже самое сработает и с условиями, и с проверками, и с альтернативами!

Реальные примеры

Статья уже получилась довольно длинной. И боюсь ее сложно будет читать. Поэтому на сегодня я закончу обзор ООП в Wolrfam Language, но прежде всего хочу показать, что все то, о чем я писал выше не просто игрушка, а используется мной и моими товарищами в наших пет-проектах. Именно эту реализацию изменяемых объектов я постепенно добавляю во многие свои проекты и она работает замечательно, хоть в ней есть далеко не все что я запланировал. Вот например код, который используется в библиотеке TelegramBot:

Инициализация бота при вызове конструктора
Инициализация бота при вызове конструктора

Обратите внимание, что конструктор телеграм-бота переопределен. В качестве параметра он теперь принимает еще и строку с уникальный токеном бота.

А вот еще пример использование объектов в TCPServer, который работает полностью на WL:

На скриншоте определение точки входа - обработчика TCP-пакетов
На скриншоте определение точки входа - обработчика TCP-пакетов

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

У меня еще много примеров и мыслей, которыми я бы хотел поделиться. Плюс я прошелся не по всем пунктам комментария из самого начала. Я отлично знаю, что тема парадигм программирования и подходов к программированию может вызвать интересную дискуссию, так как эту тему любят на Хабре. Буду рад выслушать мнение уважаемых читателей!

Всем спасибо за внимание!

Теги:
Хабы:
Всего голосов 11: ↑11 и ↓0+11
Комментарии15

Публикации

Истории

Ближайшие события

AdIndex City Conference 2024
Дата26 июня
Время09:30
Место
Москва
Summer Merge
Дата28 – 30 июня
Время11:00
Место
Ульяновская область