Pull to refresh

Стиль написания кода на Wolfram Language

Level of difficultyEasy
Reading time18 min
Views3.5K
Роберт знает толк в стиле
Роберт знает толк в стиле

Я как большой фанат Wolfram Language (WL) очень часто изучаю открытые репозитории с кодом на этом языке. Изучив достаточно много кода я заметил, что стиль написания этого кода очень сильно разнится от проекта к проекту. Но так же я изучил много встроенных пакетов в Mathematica/Wolfram Language, которые были написаны разработчиками из Wolfram Research. В большинстве случаев они были написаны еще хуже (т.е. более неструктурированно и без единого стиля) чем пакеты такого же объема и сложности в открытом доступе. Но и среди проектов на GitHub и среди пакетов в языке мне попадались те, которые действительно хорошо написаны. Постепенно у меня сформировалось понимание того стиля, который будет наиболее прост и понятен большинству пользователей WL. В этой статье я хочу поделиться своим мнением и задокументировать тот стиль и ту конвенцию, которую я постепенно выработал для себя. Возможно, это станет еще кому-то полезно и изучив от корки до корки эту статью, а лучше вызубрив, чтобы от зубов отскакивало, вы станете так быстро решать уравнения и строить графики, что...

Системные функции

В Wolfram Language существует огромное количество встроенных функций. И подавляющее большинство имен функций подчиняются следующим правилам:

  • Все системные функции и переменные начинаются с прописной буквы на латинице

  • Все системные функции языка записываются в PascalCase

  • В именах используются символы латиницы и достаточно редко цифры. Символ подчеркивания синтаксис не поддерживает

  • Еще одно важное соглашение - все встроенные функции представляют собой набор из полных слов английского алфавита и должны легко читаться

  • Большинство классических функций по своему смыслу являются глаголами, которые обозначают действие

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

  • Еще одно правило касается функций, которые называются именами ученых - чаще всего такие функции имеют префикс в виде имени ученого, а далее идет стандартное принятое обозначение этой функции.

  • Общепринятые аббревиатуры используются как есть, если встречаются в именах, например URL, HTTP.. в отличии, например, от C#, где аббревиатура URL в именах классов и методов пишется как Url.

Ниже примеры имен встроенных функций:

Plot     (*строит аналитический график*)
Solve    (*аналитически решает уравнение*)
FindRoot (*численно ищет корни уравнения*)
Replace  (*заменяет части выражений*)

И пример функций с устоявшимися в математике именами:

Sin        (*синус*)
Cos        (*косинус*)
Tan        (*тангенс*)
Cosh       (*гиперболический косинус*)
BesselJ    (*функция Бесселя*)
BesselY    (*эту чаще называют функцией Неймана. Небольшая несостыковка*)
EulerGamma (*гамма-функция Эйлера*)
URLRead    (*вызывает cURL*)

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

Встроенные переменные

В Wolfram Language существует два типа встроенных переменных или символов. Во-первых, это могут быть константы или символы с отложенным вычислением. Тогда они подчиняются тем же правилам, что и встроенные функции, то есть PascalCase в виде полных английских слов, если для этого символа нет устоявшегося сокращения. Единственное отличие в том, что по смыслу это обычно не действие. Вот пример таких констант и символов:

Pi    (*число пи*)
I     (*мнимая единица*)
E     (*экспонента*)
Today (*сегодняшняя дата*)
Now   (*текущее время*)
Red   (*переменная хранящий красный цвет*)
Large (*символ без значения нужен для стилизации*)

Второй вид встроенных переменных - это системные константы. Их принято записывать с префиксом в виде $:

$MachinePrecision      (*машинная точность*)
$MachineEpsilon        (*машинная погрешность*)
$HistoryLength         (*размер истории вычислений*)
$Context               (*текущий контекст*)
$CloudCreditsAvailable (*число доступных в облаке кредитов*)

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

Интерактивный режим

То, в каком стиле писать код на Wolfram Language зависит от того, что вы делаете. Рабочий процесс разработки пакетов, скриптов и создания интерактивных документов отличается.

Режим работы в интерактивных документах похож на то, как происходит разработка в Jupyter Notebook (хотя если быть честным, то это Jupyter Notebook похож на Mathematica, так как изначально IPython, как предшественник Jupyter был разработан под влиянием Mathematica). В интерактивных блокнотах можно создавать ячейки с кодом, текстом и форматированием, выполнять ячейки с кодом и тут же видеть результат. Поэтому в интерактивных блокнотах могут быть:

  • Ячейки с длинными последовательностями команд, которые можно назвать скриптами

  • Создание переменных

  • Создание собственных функций

  • Вызов встроенных и собственных функций

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

Модель SIR
Модель SIR

А вот про такое оформление блокнота я бы сказал, что оно "не самое удачное":

Все в одной ячейки без возможности отладки по отдельности
Все в одной ячейки без возможности отладки по отдельности

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

В конце каждой строки с кодом нужно ставить символ ";" если не требуется выводить результат выполнения строки. Если нужно напечатать результат в выходную ячейку - то ";" не ставится:

График функции y = sin(x) по точкам
График функции y = sin(x) по точкам

Собственные функции и переменные в блокноте

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

  • Все имена - полные слова на английском

  • Имена состоят из символов латиницы

  • Первая буква всегда строчная, т.е. в camelCase, таким образом минимальный шанс создать функцию и переменную, имя которой совпадет со встроенной

  • Как и системные функции - собственные функции обозначают действие

  • Собственные переменные - существительное

  • Не использовать абстрактные, однобуквенные имена или буква + цифры, за исключением общепринятых обозначений итераторов: i, k, j, m...

А теперь несколько примеров таких функций:

addOne[x_] := x + 1 (*прибавляет единицу*)
getPhoneNumber[text_] := StringCases[text, phonePattern[]]
createWebhook[address_] := {(*тут код*)} (*создает веб-хук*)

и переменных:

phones = {"+79991234568", "89007654321"} (*список телефонов*)
currencyData = <|"ISO" -> "RUB", "Rate" -> 1, "Data" -> "2024.01.01"|>

Использование итератора:

i = 10; 
While[i > 0, i = i - 1; Print["current i = ", i]]; 

Аргументы функций и локальные переменные

Для имен аргументов функций и локальных переменных применимы следующие правила:

  • Они могут быть записаны либо в том виде, в каком это принято для глобальных переменных, то есть в camelCase полными словами

  • Часто именно для них можно использовать абстрактные имена, когда это математическая функция

  • Либо можно использовать сокращения

Например, для математической функции можно использовать типичные общепринятые имена аргументов вроде {n, x, y, t} и другие если у переменной нет конкретного смысла:

getNextPrimeYear[n_] := 
  NextPrime[DateList[][[1]], n] (*n - обычно обозначает целый аргумент*)

gravityForce[r_, m1_, m2_] := g * m1 * m2 / r^2 (*для функций, 
                                                  которые применимы в физике 
                                                  и других науках это тоже подходит*)

Локальные переменные и константы создаются внутри таких функций как Module, Block и With:

Module[{db = bot["Database"]}, 
  createTable[db, "users", {"id", "name", "phone"}]
]

With[{usersTable = bot["Database", "users"]}, 
  Append[usersTable, <|"name" -> "Kirill", "phone" -> "+79991234568"|>]
]

Опции функций

У любой функции в WL могут быть необязательные аргументы в виде опций. Ниже код такой функции:

Options[sendMessage] = {
  "ParseMode" -> "html"
}; 


sendMessage[bot_, chat_, message_, OptionsPattern[]] := 
botAPI["sendMessage", {
  "api_token" -> bot["Token"], 
  "chat_id" -> chat, 
  "message" -> message, 
  "parse_mode" -> OptionValue["ParseMode"]
}]; 


sendMessage[bot, 1234, "hello", ParseMode -> "markdown"]

И для опций справедливы следующие правила:

  • Опции объявляются до создания определения функции

  • Имена опций принято задавать в виде строк

  • Имена должны быть в PascalCase и как выше - полные английские слова

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

Сообщения

Для любого символа в языке Wolfram можно создать дополнительное сообщение. Обычно это делается в двух случаях:

  • Создание описания функции для авто-дополнения

  • Создания сообщений об ошибках

Ниже пример функции с двумя сообщениями:

createChat::usage = "createChat[chatName, user] creates chat for user.";


createChat::nousr = "User `1` not exists."; 


createChat[chatName_, user_?userExistsQ] := callAPI["createChat", {
  "user" -> user, 
  "chat_name" -> chatName
}]; 


createChat[chatName_, invalidUser_] := Message[createChat::nousr, invalidUser]; 

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

Для имен сообщений справедливы следующие правила:

  • Имена сообщений пишутся в lowercase

  • Длинные имена сокращаются удалением гласных

  • Рекомендуется использовать от 4 до 8 символов на имя сообщения

Пакеты

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

Во-первых, что же такое пакет? Это файл с исходным кодом на Wolfram Language. Как выше я привел аналогию между блокнотами Mathematica и Jupyter, так и здесь проще всего сказать, что пакет WL это аналог пакета для Python. Тем более, структура файлов для них наиболее похожа.

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

[MyPackage]
- [Documentation]
- - [English]
- - - ..
- [Kernel]
- - MyPackage.wl
- PacletInfo.wl
  • [MyPackage] - директория пакета

  • [Documentation]/[English] - директория с документацией

  • [Kernel] - директория с исходным кодом

  • MyPackage.wl - файл с исходным кодом

  • PacletInfo.wl - файл с метаинформацией о пакете

Выше представлена минимальная структура файлов для пакета написанного по текущим стандартам, который предложен в Wolfram Research. Нас в первую очередь интересует файл с метаинформацией. Его содержание обычно выглядит вот так:

PacletObject[
  <|
    "Name" -> "KirillBelov/MyPackage",
    "Description" -> "My package",
    "Creator" -> "Kirill Belov",
    "Version" -> "1.0.0",
    "WolframVersion" -> "13.3+",
    "PublisherID" -> "KirillBelov",
    "License" -> "MIT",
    "PrimaryContext" -> "KirillBelov`MyPackage`",
    "Extensions" -> {
      {
        "Kernel",
        "Root" -> "Kernel",
        "Context" -> {"KirillBelov`MyPackage`"}
      },
      {
        "Documentation",
        "Root" -> "Documentation",
        "Language" -> "English"
      }
    }
  |>
]

Это очень похоже на package.json для пакетов npm, только вместо JSON в файле записано выражение на WL. Большинство ключей и их значения в примере выше интуитивно понятны. Контекстом называют пространство имен для функций пакета. И я должен пояснить почему имя пакета и контекст имеют такой вид. Дело в том, что текущие рекомендации по разработке пакетов (их еще называют паклетами) на языке Wolfram специально были адаптированы для Wolfram Language Paclet Repository. И по правилам теперь имя пакета и контекст должны в качестве префикса содержать имя разработчика чтобы не возникало коллизии. Это может быть логин содержащий реальное имя (как у меня), псевдоним или название компании. Чаще всего пользователи используют свои имена или имя пользователя на GitHub. Теперь перейдем к правилам создания файлов с исходным кодом.

Одиночный файл с кодом

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

Допустим я создаю пакет как в разделе выше. Мой пакет называется MyPackage. Мой ID на Paclet Repository - это KirillBelov. Поэтому контекст (пространство имен) будет KirillBelov`MyPackage`, т.е. составлено из моего ID и названия пакета. Тогда файл с исходным кодом должен располагаться в папке Kernel и имя файла совпадает с именем пакета. Именно в этом файле должен быть объявлен контекст пакета следующим образом:

BeginPackage["KirillBelov`MyPackage`"]; 


Begin["`Private"]; 


End[]; 


EndPackage[]; 
  • BeginPackage - объявляет новый пакет с указанным контекстом

  • Begin - объявляет новый внутренний контекст

  • End - закрывает внутренний контекст

  • EndPackage - закрывает пакет

Между строками BeginPackage и Begin (публичный контекст) необходимо размещать объявление публичных функций пакета. По рекомендациям WRI это делается при помощи создания сообщения usage вот так:

BeginPackage["KirillBelov`MyPackage`"]; 


MPAddOne::usage = "MPAddOne[x] adds one to x and return result.";


Begin["`Private"];

Далее между Begin и End (приватный контекст) должна быть реализация этой функции:

Begin["`Private"];


MPAddOne[x_] := x + 1; 


End[]; 

В принципе минимальный пакет готов. Если он установлен в систему, то его можно загрузить в сессию вот так:

<<KirillBelov`MyPackage`

А затем использовать публичную функцию вот так:

MPAddOne[2] (* => 3 *)

При этом в интерфейсе Mathematica (или в WLJS) при вводе появится вот такая подсказка:

Авто-дополнение и шаблон аргументов
Авто-дополнение и шаблон аргументов

Оформление кода пакета

Ну а теперь собственно к правилам оформления кода.

  • Между выражениями и определениями в пакете необходимо ставить две пустые строки. Как в примерах кода выше. Две пустые строки Mathematica автоматически распознает как границу между ячейками и отображает код пакета в наиболее удобном виде:

    Один и тот же код в VS Code и в Mathematica
    Один и тот же код в VS Code и в Mathematica
  • В конце каждой строки рекомендуется ставить символ ";", так как в Mathematica пробел может интерпретироваться как умножение. Часто (даже в исходниках Mathematica) я встречаю ошибки с количеством скобок, которые не приводят к падению загрузки пакета, а просто умножают две несвязанные строки.

Имена публичных функций пакета

Все собственные функции пакета из публичного контекста должны быть:

  • В PascalCase в виде осмысленных английских слов/фраз/предложений и обозначать действие, т.е. в точности, как встроенные функции

  • Но в отличии от встроенных функций, для функций пакетов необходимо указывать префикс, который указывает на имя пакета, в котором определена функция

  • Префикс может быть очевидным сокращением или полным названием пакета. Например, в пакете OpenAILink` все функции начинаются с OpenAI*. А функции загруженные из пакета NeuralNetworks` начинаются с префикса Net*.

Следуя этим правилам я назвал функцию из демо-пакета как MPAddOne. А также добавил описание для этой функции в виде сообщения usage. Сообщение составляется по следующему шаблону:

MyFunc::usage = "MyFunc[args] what do MyFunc."; 

То есть в начале строки описания нужно обязательно написать функцию + аргументы, а дальше описание того, что функция делает. В конце ставится точка. Кроме того можно создать описание для каждой перегрузки функции:

MyFunc::usage = 
"MyFunc[x] add 1 to x.
MyFunc[x, y] add y to x."; 

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

Функция с двумя перегрузками
Функция с двумя перегрузками

Публичные константы и символы пакета

Часто бывает так, что необходимо создать не только набор функций, но и хранить некоторые данные пакета. Для этих целей используют символы с отложенным вычислением или константы. Все они записываются как системные константы, но внутри пакета. Их так же необходимо объявлять с помощью usage, но без шаблона вызова. Хороший пример такого пакетного символа - это ключ $OpenAIKey из пакета OpenAILink`. Для демо-пакета я создам константу, которая хранит текущую директорию установки. Это распространенная практика и она бывает очень полезна, если вместе с паклетом в систему устанавливается папка с примерами данных, которые пользователю нужно показать:

$MyPackageDirectory::usage = "My package directory.";

Begin["`Private`"]; 

$MyPackageDirectory = DirectoryName[$InputFileName]; 

Т.е. публичные константы пакета:

  • Объявляются с usage

  • Содержат префикс из $ + имя пакета или сокращение

  • Записаны в PascalCase

  • Хранят данные связанные с самим пакетом

Приватные имена пакета

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

  • camelCase из полных слов английского языка

  • Функции обозначают действия

  • Префиксы не требуются, так как приватные функции видны только в приватном контексте

  • Описание в виде usage не требуется

  • Все тоже самое касается приватных переменных пакета и констант

Ниже пример такой функции:

Begin["`Private`"]; 


MyFunc[x_] := myPrivateFunc[x, 1]; 


myPrivateFunc[x_, y_] := x + y; 


End[]; 

Несколько исходных файлов

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

[MyPackage]
- [Documentation]/..
- [Kernel]
- - MyPackage.wl
- - API.wl
- - DB.wl
- PacletInfo.wl
  • Обязателен файл MyPackage.wl, который в данном случае стоит называть инициализирующим

  • API.wl и DB.wl содержат специфичные связанные функции

  • PacletInfo.wl необходимо дополнить, чтобы он новую отражал структуру

Во-первых, как нужно изменить PacletInfo.wl чтобы он поддерживал такую структуру паклета? Необходимо в раздел "Extensions"/"Kernel"/"Context" добавить все файлы и соответствующие им контексты вот так:

"Extensions" -> {
  {
    "Kernel",
    "Root" -> "Kernel",
    "Context" -> 
    {
      {
  	    "KirillBelov`MyPackage`", 
        "MyPackage.wl"
      },
      {
        "KirillBelov`MyPackage`API`",
        "API.wl"
      },
      {
        "KirillBelov`MyPackage`DB`",
        "DB.wl"
      }
    }
  }
}

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

Поделив исходный код на несколько файлов - мы должны создать специальный инициализирующий файл, имя которого совпадает с именем паклета. Теперь MyPackage.wl должен содержать вот такой код:

BeginPackage["KirillBelov`MyPackage`"]; 


EndPackage[]; 


Get["KirillBelov`MyPackage`API`"]; 


Get["KirillBelov`MyPackage`DB`"]; 

Код выше сначала создает основной контекст без определений, а затем загружает дочерние контексты. Поиск дочерних контекстов (ведь имена файлов напрямую не указаны) происходит при помощи мета-информации в PacletInfo.wl. А вот такой код будет в API.wl:

(* ::Package:: *)

BeginPackage["KirillBelov`MyPackage`API`"];


$MyPackageAPIVersion::usage = "current version of the API."; 


MyPackageGetCurrencyRates::usage = 
"MyPackageGetCurrencyRates[] returns current rates.";


Begin["`Private`"]; 


$MyPackageAPIVersion = "1.0.0"; 


MyPackageGetCurrencyRates[] := {
  <|"ISO" -> "RUB", "Rate" -> 1.0|>, 
  <|"ISO" -> "USD", "Rate" -> 30.0|>
};


End[]; 


EndPackage[]; 

Допустим, дочерний пакет DB.wl должен использовать функции из API.wl. Это необходимо указывать в виде списка используемых контекстов при создании контекста вторым аргументом функции BeginPackage:

(* ::Package:: *)

BeginPackage["KirillBelov`MyPackage`DB`", {
  "KirillBelov`MyPackage`API`"
}];


MyPackageDBSaveCurrency::usage = 
"MyPackageDBSaveCurrency[currency] saves currency to database.
MyPackageDBSaveCurrency[] saves all currencies to database";


Begin["`Private`"]; 


MyPackageDBSaveCurrency[currency_] := With[{db = getDBInstance[]}, 
  AppendTo[db, currency]; 
];


MyPackageDBSaveCurrency[] := 
Map[MyPackageDBSaveCurrency] @ MyPackageGetCurrencyRates[];


End[]; 


EndPackage[]; 

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

Кроме определений у меня в файле еще есть самоочевидные комментарии
Кроме определений у меня в файле еще есть самоочевидные комментарии

Скобочки, переносы, пробелы и сокращения

В Wolfram Language есть сразу несколько видов скобочек и огромное количество синтаксического сахара. строгих требований нет, но я опишу тот формат, который на мой взгляд наиболее удобен:

  • Скобочки, функции и локализующие конструкции в java-style

  • Пробелы слева и справа от операторов:
    {+, -, *, /, ^, &&, ||, >=, <=, ==, ===, >, <}

  • Пробелы слева и справа от присваиваний и правил:
    {=, :=, ->, :>, /:, /;, /., /*, ...}

  • Пробелы после запятых, двоеточия и точки с запятой

  • Отдельно-стоящие списки и ассоциации в C#-style

  • Строки кода более 120 символов нежелательны

  • Табуляция в 4 проблема. По умолчанию Mathematica использует символ табуляции

  • Не злоупотребляйте псевдонимами и сокращениями

Собственно несколько примеров:

myFunc1[x_, y_] := Module[{z = x + y}, 
    z + z ^ 2 - z / 2 * 3
] (*операторы, локализующая функция и присваивание*)


(*правила, замена, условный оператор и условие*)
{1, 2, 5, 4, 2, 0} //. {f___, x_, y_, r___} :> {f, y, x, r} /; y < x


(*список рядом с "=" в java-style и отдельная ассоциация в C#-style*)
users = {
    <|
        "Name" -> "Kirill", 
        "Language" -> "WL"
    |>
}

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

Просто кошка пробежалась по клавиатуре
Просто кошка пробежалась по клавиатуре

Определения функций

И последний раздел о том, как рекомендуется создавать определения. Эти рекомендации помогут правильно организовать самую важную составляющую любого проекта на WL - собственные функции (а как мы выяснили типичный проект на WL - это набор функций, а затем их вызов):

  • Рекомендуется указывать типы параметров или накладывать на них условия

  • Не использовать глобальные переменные

  • Использовать локальные переменные и Module

  • Всегда возвращать результат

  • Желательно указывать в комментариях тип результата

  • Накладывать условия и печатать сообщения при неправильном вызове

  • Не менять состояние окружения, а только преобразовывать данные, если этого не требует бизнес-логика

А теперь я поясню отдельно каждый пункт.

Указание типов. Это делается очень легко и очень помогает быстро понимать что делает функция. Можно сравнить код двух функций ниже и польза будет очевидна:

selectDates[data_] := Select[data, DateObjectQ]; 


selectDates[data_Association] := Select[data, DataObjectQ]; 

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

Не использовать глобальные переменные. Это очевидно плохо, так как состояние переменные всегда может измениться.

Локальные переменные и Module. Рекомендуется в общем случае использовать именно Module, так как эта функция создает новые уникальные переменные внутри и удаляет их после выполнения блока кода. Пусть имеется определение:

addSqrt[x_] := Module[{sqrt}, 
  sqrt = x ^ 2; 
  x + sqrt
]; 


addSqrt[2] (* => 6 *)

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

sqrt = 4; 


addSqrt[3] (* => 12 *)
sqrt       (* => 4  *)

Но если убрать Module, то произойдет следующее:

sqrt = 4; 


addSqrt[x_] := (
  sqrt = x ^ 2; 
  x + sqrt
); 


addSqrt[3] (* => 12 *)
sqrt       (* => 9  *)

Почему не рекомендуется в общем случае использовать Block? Это функция очень похожа на модуле, но она не создает в момент выполнения новой переменной. Вместо этого она очищает значение локальной переменной. Пока что разница не ясна, но я покажу ее на примере кода. Пусть есть несколько определений, которые зависят друг от друга:

(*выбирает максимальную точку из двух по модулю*)
maxPoint[{p1_List, p2_List}] := If[Total[p1^2] > Total[p2^2], p1, p2]; 


(*получает значение компоненты X максимальной точки*)
getMaxX[{p1_List, p2_List}] := maxPoint[{p1, p2}][[1]]; 

А теперь я создам третьею функцию забыв о том, что вторая вызывает первую. И внутри использую локальную переменную maxPoint:

moveMaxX[{p1_, p2_}] := 
Module[{maxPoint}, 
  getMaxX[{p1, p2}] + 1
]; 


moveMaxX[{{1, 2}, {3, 4}}] (* => 3 + 1 == 4 *)


moveMaxX[{p1_, p2_}] := 
Block[{maxPoint}, 
  getMaxX[{p1, p2}] + 1
]; 


moveMaxX[{{1, 2}, {3, 4}}] (* => {{2, 3}, {4, 5}} *)
Код одинаковый, только локализующая функция разная
Код одинаковый, только локализующая функция разная

Что произошло на скриншоте выше? В первом случае с Module все сработало правильно, а во втором с Block определение maxPoint было очищено на время вызова функции, а значит оно не сработало когда функцию maxPoint пыталась вызвать функция getMaxX. По сути определение maxPoint перестало существовать внутри Block для всех других функций. И в итоге результат получился непредсказуемым. Поэтому функцию Block рекомендуется использовать только если пользователь точно знает, что требуется сделать, а во всех остальных случаях использовать Module.

С With ситуация еще сложнее. По сути в нем создаются не переменные а константы. Продемонстрировать это можно вот так:

Module[{arr = {1, 2, 3}}, 
  AppendTo[arr, 4]
] (* => {1, 2, 3, 4} *)


With[{arr = {1, 2, 3}}, 
  AppendTo[arr, 4]
] (* => error *)

Во втором случае arr сам по себе является неизменяемым списком, а значит на нем нельзя вызывать функцию AppendTo и вообще работать как с переменной. Нужно просто учитывать, что при использовании With на место arr подставится {1, 2, 3} именно в таком виде.

Возвращение результата. Wolfram Language оперирует неизменяемыми выражениями. А значит в нем не принято (хотя технически можно) вызывать функции, которые ничего не возвращают, но меняют состояние окружения. Все вызовы функций должны принимать на вход аргументы и возвращать преобразованный результат.

Указание типа результат. Это опциональная рекомендация. Я часто этим пользуюсь, чтобы сделать код более читаемым. Вот пример моего кода, где я это активно использовал:

Функция может вернуть результаты разного типа
Функция может вернуть результаты разного типа

Сообщения. Это очень важный пункт, про который многие забывают. Либо его просто игнорируют, так как он не влияет напрямую на функциональность. Но сообщения об ошибках помогают в отладке и рефакторинге кода. При создании определения рекомендуется создать дополнительную перегрузку которая вернет Null и напечатает сообщение с ошибкой и некорректными аргументами. Это делается вот так:

(*само сообщение*)
myFunc::argx = "`1` called with `2` arguments. `3` arguments is expected."; 


(*ошибка аргументов*)
myFunc[args___] := Message[myFunc::argx, myFunc, Length[{args}], 2]; 


(*основное определение*)
myFunc[x_, y_] := x + y; 


(*вызов функции*)
myFunc[1, 2]
myFunc[3]
Сообщение, которое однозначно указывает на функцию, в которой была ошибка
Сообщение, которое однозначно указывает на функцию, в которой была ошибка

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

users = {
   <|"Username" -> "Kirill", "Language" -> "WL"|>,
   <|"Username" -> "Eugeny", "Language" -> "C#"|>
};


addUser[table_List, user_Association] := (
   AppendTo[table, user];
   table = DeleteDuplicatesBy[table, #Username &];
   table
);


addUser[users, <|"Username" -> "Ivan", "Language" -> "Russian"|>]
А вот и сообщения о не валидных аргументах
А вот и сообщения о не валидных аргументах

В примере выше ошибка заключается в том, что при вызове функции на место users сразу подставляется неизменяемое выражение, к которому нельзя ничего добавить. Функции AppendTo и Set работают только на символах, но как этого добиться? Нужно не дать вычислиться переменной users! Делается это при помощи добавления атрибута HoldFirst, который удерживает аргумент от вычисления:

users = {
   <|"Username" -> "Kirill", "Language" -> "WL"|>,
   <|"Username" -> "Eugeny", "Language" -> "C#"|>
};


SetAttriutes[addUser, HoldFirst]; 


addUser[table_?ListQ, user_Association] := (
   AppendTo[table, user];
   table = DeleteDuplicatesBy[table, #Username &];
   table
);


addUser[users, <|"Username" -> "Ivan", "Language" -> "Russian"|>]
Теперь users изменился
Теперь users изменился

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

Заключение

Соблюдаю ли я сам все эти правила? Конечно же нет. Очень часто я использую то, что позволяет язык, но я не встречал этого ни в одном проекте и репозитории. Я очень сильно страдаю когда смотрю на свой же код, который идет в разрез описанному выше стилю и пытаюсь его исправить - сделать более читабельным. Иногда получается, а иногда нет. Когда я только начинал изучать WL меня просто поразило многообразие и сложность его синтаксиса (а точнее синтаксического сахара). Я старался использовать его везде где только мог в самых невообразимо-сложных формах. Затем, когда я возвращался к своему же коду - то был просто в ужасе! Теперь только Господь знал, что делает мой собственный код. И вот спустя годы использования WL я постарался отбросить все сложности, писать код как можно проще и сохранить свои текущие представления о красивом коде здесь.

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

Tags:
Hubs:
Total votes 14: ↑14 and ↓0+14
Comments28

Articles