Русский
Русский
English
Статистика
Реклама

Macro

Автоматическая генерация type classes в Scala 3

07.01.2021 16:12:32 | Автор: admin

В Scala широко используется подход к наделению классов дополнительной функциональностью, называемый type classes. Для тех, кто никогда не сталкивался с этим подходом рекомендую почитать вот эту статью. Этот подход позволяет держать код каких-то аспектов функционирования класса отдельно от самой реализации класса. И создавать его даже не имея доступа к коду самого класса. В частности, такой подход оправдан и рекомендуем при наделении классов возможностью сериализации/десериализации в определенный формат. Например библиотека работы с Json из фреймворка Play использует type classes для задания правил представления объектов в json формате.

Если type class предназначен для использования в большом количестве разнообразных классов (как например при сериализации/десериализации), то писать код type class для каждого класса с которым он должен работать нерационально и трудозатратно. Во многих случаях можно сгенерировать реализацию type class автоматически зная набор атрибутов класса для которого он предназначается. К сожалению в текущей версии scala автоматическая генерация type class затруднена. Она требует либо самостоятельного написания макросов, либо использования сторонних фреймворков для генерации type class таких как shapeless или magnolia, которые также основаны на макросах.

В Scala 3, которая стремительно движется к релизу появилась встроенная в язык возможность автоматической генерации type class. В этой статье делается попытка разобраться с использованием этого механизма на примере конкретного type class.

Объявление type class

В качестве примера будет использоваться достаточно искусственный type class который мы назовем Inverter. Он будет содержать один метод:

trait Inverter[T] {  def invert(value: T): T}

Предполагается что он будет принимать значение и каким либо образом "инвертировать" его. Для строк под инвертированием мы будем понимать изменение порядка символов на противоположный, для чисел - изменение знака числа, а для логического типа - применение логического NOT. Для всех структурных типов инвертированием будет инвертирование значений всех их атрибутов. Такой type class имеет мало практического смысла, но для нас он хорош тем, что позволяет продемонстрировать как обработку входящего значения базового класса так и выдачу результата в виде значения базового класса.

Итак первое что нужно сделать - это определить type class для элементарных типов. Делается это объявлением given значений (аналог implicit из Scala 2) с реализацией Inverter в объекте компаньоне Inverter:

object Inverter {  given Inverter[String] = new Inverter[String] {    override def invert(str: String): String =      str.reverse  }  given Inverter[Int] = new Inverter[Int] {    override def invert(value: Int): Int =      -value  }    given Inverter[Boolean] = new Inverter[Boolean] {    override def invert(value: Boolean): Boolean =      !value  }  }

Теперь займемся автоматической генерацией Inverter для сложных типов. Для того чтобы автоматическая генерация была возможна необходимо объявить в объекте компаньоне метод derived[T] возвращающий Inverter[T]. Реализация этого метода может быть любой. Например можно генерировать type class с помощью макроса или при помощи высокоуровневой библиотеки генерации (например shapeless 3). Нас же будет интересовать генерация через встроенный низкоуровневый механизм. Для его работы метод derived должен получить контекстный параметр типа Mirror.Of[T]. Этот параметр позволит нам получить информацию о структуре нашего типа. Параметр Mirror.Of[T] генерируется компилятором автоматически для следующих типов:

  • case классы и case объекты

  • перечисления (enum и enum cases)

  • sealed trait-ы единственными наследниками которых являются case классы и case объекты.

Собственно этот список это и есть тот список классов для которых может автоматически генерироваться type class при использовании описываемого механизма.

Сразу нужно отметить что это не runtime механизм получения информации о типе во время исполнения, а compile time механизм использующий новый фичи Scala 3 по кодогенерации (это объясняет, в частности, то, что большинство методов генерации должны объявляться как inline).

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

  inline def derived[T](using m: Mirror.Of[T]): Inverter[T] = {    val elemInstances = summonAll[m.MirroredElemTypes]    inline m match {      case p: Mirror.ProductOf[T] =>        productInverter[T](p, elemInstances)      case s: Mirror.SumOf[T] => ???    }  }  inline def summonAll[T <: Tuple]: List[Inverter[_]] =    inline erasedValue[T] match      case _: EmptyTuple => List()      case _: (t *: ts) => summonInline[Inverter[t]] :: summonAll[ts]

Разберемся что здесь происходит. Для всех структурных типов Miror.Of[T] позволяет определить типы элементов класса через MirroredElemTypes. Для случая case классов и кортежей это просто типы всех полей. Поскольку для инвертирования нашего типа нам надо инвертировать все его поля, то нам необходимо получить экземпляры Inverter для всех типов полей нашего класса. Это делается через метод summonAll. Реализация summonAll использует новый механизм поиска given значений summonInline. Мы сейчас не будет останавливаться на тонкостях этой реализации, так как для наших целей реализация метода summonAll будет всегда одинаковой независимо от того какой type class мы генерируем.

После получения списка Inverter для всех элементов класса мы определяем чем является наш класс - произведением других классов (case классы, case объекты, кортежи) или суммой (sealed trait или enum). Поскольку сейчас нас интересуют только случай произведения, то для этого случая вызывается метод productInverter, который создает имплементацию Inverter на основе Inverter для всех элементов класса:

def productInverter[T](p: Mirror.ProductOf[T], elems: List[Inverter[_]]): Inverter[T] = {    new Inverter[T] {      def invert(value: T): T = {        val oldValues = value.asInstanceOf[Product].productIterator        val newValues = oldValues.zip(elems)          .map { case (value, inverter) =>            inverter.asInstanceOf[Inverter[Any]].invert(value)          }          .map(_.asInstanceOf[AnyRef])          .toArray        p.fromProduct(Tuple.fromArray(newValues))      }    }  }

Реализация этого метода делает следующее. Во-первых, получается список значений всех полей экземпляра класса. Так как мы знаем что наш класс является произведением типов то он реализует trait Product, который позволяет получить итератор всех значений полей. Во-вторых список значений полей объединяется со списком Inverter для них и для каждого поля к значению применяется свой Inverter. Наконец, в-третьих, из списка инвертированных значений собирается новый экземпляр класса. За эту сборку отвечает метод fromProduct доступный через Mirror объект.

Использование derived

Каким же образом теперь можно использовать созданный метод derived для получения type class для конечного класса. Тут есть несколько подходов. Самый простой - использовать при объявлении case класса конструкцию derives которая указывает что для этого класса необходимо сгенерировать указанный type class. Вот пример такого объявления:

case class Sample(intValue: Int, stringValue: String, boolValue: Boolean) derives Inverter

После этого экземпляр Inverter[Sample] будет сгенерирован и доступен везде где виден класс Sample. Далее мы просто можем получать его через summon и использовать:

println(summon[Inverter[Sample]].invert(Sample(1, "abc", false)))// Результат: Sample(-1,cba,true)

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

Однако часто мы не хотим или не можем в явном виде модифицировать класс, для которого нам нужен type class. В этом случае мы можем объявить given значение для этого класса пользуясь методом derived:

case class Sample(intValue: Int, stringValue: String, boolValue: Boolean)@main def mainProc = {    given Inverter[Sample] = Inverter.derived  println(summon[Inverter[Sample]].invert(Sample(1, "abc", false)))  // Результат: Sample(-1,cba,true)  } 

Нужно однако понимать что такая генерация type class является полуавтоматической. Для вложенных case классов type class автоматически сгенерирован не будет. Скажем для иерархии:

case class InnerSample(s: String)case class OuterSample(inner: InnerSample)

необходимо будет последовательно сгенерировать необходимые type class:

  given Inverter[InnerSample] = Inverter.derived  given Inverter[OuterSample] = Inverter.derived  println(summon[Inverter[OuterSample]].invert(OuterSample(InnerSample("abc"))))  // Результат: OuterSample(InnerSample(cba))

В большинстве случаев однако можно разрешить компилятору автоматически генерировать type class для всех типов для которых доступен Mirror.Of. Для этого просто объявляем универсальный given:

  inline given[T] (using m: Mirror.Of[T]): Inverter[T] = Inverter.derived[T]    println(summon[Inverter[OuterSample]].invert(OuterSample(InnerSample("abc"))))  // Результат: OuterSample(InnerSample(cba))

По какому пути идти и насколько автоматизировать генерацию type class в каждом конкретном случае нужно решать индивидуально. Разработчикам библиотек я бы рекомендовал прятать автоматическую генерацию в отдельный trait или объект, которые можно подключить через наследование (или import соответственно) там, где это необходимо:

trait AutoInverting {  inline given[T] (using m: Mirror.Of[T]): Inverter[T] = Inverter.derived[T]}

Кастомные type class и автоматическая генерация

Автоматическая генерация type class вполне допускает использование type class созданных вручную для тех типов где нужна специальная реализация. При этом при эти созданные вручную реализации будут использоваться в том числе и там где они входят в качестве элементов в другие классы.

Например рассмотрим следующую иерархию case классов:

case class SampleUnprotected(value: String)case class SampleProtected(value: String)case class Sample(prot: SampleProtected, unprot: SampleUnprotected)

Допустим для класса SampleProtected мы хотим иметь специальную реализацию Inverter, которая не инвертирует его поле value. Посмотрим как будет это сочетаться с автоматической генерацией type class для Sample:

  inline given[T] (using m: Mirror.Of[T]): Inverter[T] = Inverter.derived[T]    given Inverter[SampleProtected] = new Inverter[SampleProtected] {    override def invert(prot: SampleProtected): SampleProtected = SampleProtected(prot.value)  }    println(summon[Inverter[Sample]].invert(Sample(SampleProtected("abc"), SampleUnprotected("abc"))))  // Результат: Sample(SampleProtected(abc),SampleUnprotected(cba))

Как видим Inverter автоматически сгенерированный для класса Sample подхватил кастомную реализацию Inverter для SampleProtected. Это позволяет определять в библиотеке автоматическую генерацию и все равно оставлять пользователю возможность делать кастомные реализации там где это необходимо.

Обработка sealed trait и enum

Помимо генерации type class для case классов (и прочих произведений классов) можно генерировать type class и для sealed trait иерархий. Для этого в методе derived необходимо дописать ветку, отвечающую за сумму классов:

  inline def derived[T](using m: Mirror.Of[T]): Inverter[T] = {    val elemInstances = summonAll[m.MirroredElemTypes]    inline m match {      case p: Mirror.ProductOf[T] =>        productInverter[T](p, elemInstances, getFields[p.MirroredElemLabels])      case s: Mirror.SumOf[T] =>         sumInverter(s, elemInstances)    }  }  def sumInverter[T](s: Mirror.SumOf[T], elems: List[Inverter[_]]): Inverter[T] = {    new Inverter[T] {      def invert(value: T): T = {        val index = s.ordinal(value)        elems(index).asInstanceOf[Inverter[Any]].invert(value).asInstanceOf[T]      }    }  }

Посмотрим что здесь происходит. В случае суммы типов типы элементы в Mirror определяют типы наследников от базового типа которые и могут выступать типом нашего экземпляра. Для того чтобы определить какой именно тип элемента нужно использовать нужно воспользоваться методом ordinal из Mirror. Он возвращает индекс типа элемента который соответствует текущему значению экземпляра. Далее мы берем соответствующий Inverter (выбирая его из списка по этому индексу) и используем для инвертирования нашего экземпляра.

Посмотрим как это работает на простейших примерах. Мы не будем создавать собственную иерархию с sealed trait а воспользуемся уже готовыми классами Either и Option:

def checkInverter[T](value: T)(using inverter: Inverter[T]): Unit = {  println(s"$value => ${inverter.invert(value)}")}  @main def mainProc = {    inline given[T] (using m: Mirror.Of[T]): Inverter[T] = Inverter.derived[T]    given Inverter[SampleProtected] = new Inverter[SampleProtected] {    override def invert(prot: SampleProtected): SampleProtected = SampleProtected(prot.value)  }    val eitherSampleLeft: Either[SampleProtected, SampleUnprotected] = Left(SampleProtected("xyz"))  checkInverter(eitherSampleLeft)  // Результат: Left(SampleProtected(xyz)) => Left(SampleProtected(xyz))  val eitherSampleRight: Either[SampleProtected, SampleUnprotected] = Right(SampleUnprotected("xyz"))  checkInverter(eitherSampleRight)  // Результат: Right(SampleUnprotected(xyz)) => Right(SampleUnprotected(zyx))  val optionalValue: Option[String] = Some("123")  checkInverter(optionalValue)  // Результат: Some(123) => Some(321)  val optionalValue2: Option[String] = None  checkInverter(optionalValue2)  // Результат: None => None  checkInverter((6, "abc"))  // Результат: (6,abc) => (-6,cba)}

Здесь мы для наглядности выделили использование Inverter в отдельный метод чтобы показать автоматическую генерацию type class без явного указания summon. Как видно генерация работает правильно и для Either и для опционального типа и для кортежей (они на самом деле обрабатываются не веткой SumOf, а веткой ProductOf).

Использование наименований полей класса

Вернемся к вопросу генерации type class для случая произведения классов и рассмотрим еще один аспект, который может оказаться важным для задач сериализации/десериализации. В нашем примере реализация инвертирования зависела только от типов полей класса, но не от названий этих полей. Однако во многих случаях реализация type class должна будет использовать наименования полей. Чтобы продемонстрировать как это можно делать введем в наш пример генерации Inverter еще одно требование: те поля класса наименование которых начинается на два символа подчеркивания инвертирование выполняться не должно. Попробуем реализовать это требование. Для этого нам понадобится реализовать метод получения списка названий полей и поправить реализацию derived:

  inline def derived[T](using m: Mirror.Of[T]): Inverter[T] = {    val elemInstances = summonAll[m.MirroredElemTypes]    inline m match {      case p: Mirror.ProductOf[T] =>        productInverter[T](p, elemInstances, getFields[p.MirroredElemLabels])      case s: Mirror.SumOf[T] =>         sumInverter(s, elemInstances)    }  }  inline def getFields[Fields <: Tuple]: List[String] =    inline erasedValue[Fields] match {      case _: (field *: fields) => constValue[field].toString :: getFields[fields]      case _ => List()    }  def productInverter[T](p: Mirror.ProductOf[T], elems: List[Inverter[_]], labels: Seq[String]): Inverter[T] = {    new Inverter[T] {      def invert(value: T): T = {        val newValues = value.asInstanceOf[Product].productIterator          .zip(elems).zip(labels)          .map { case ((value, inverter), label) =>            if (label.startsWith("__"))              value            else              inverter.asInstanceOf[Inverter[Any]].invert(value)          }          .map(_.asInstanceOf[AnyRef])          .toArray        p.fromProduct(Tuple.fromArray(newValues))      }    }  }

Проверим как работает такая реализация на следующем классе:

case class Sample(value: String, __hidden: String)

Для такого класса должно инвертироваться значение value, но не должно инвертироваться значение __hidden:

  inline given[T] (using m: Mirror.Of[T]): Inverter[T] = Inverter.derived[T]    println(summon[Inverter[Sample]].invert(Sample("abc","abc")))  // Результат: Sample(cba,abc)

Выводы

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

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

Подробнее..
Категории: Scala , Macro , Dotty , Type class

Макросы в С и С

14.03.2021 16:17:43 | Автор: admin

Макросы - один из моих самых любимых инструментов в языках С и С++. Умные люди и умные книжки советуют по максимуму избегать использования макросов, по возможности заменяя их шаблонами, константами и inline-функциями, и на то есть веские основания. С помощью макросов можно создавать не только изящный код, но и плодить не менее изящные баги, которые потом будет очень сложно отловить и пофиксить. Но если соблюдать ряд несложных правил при работе с макросами, они становятся мощным оружием, которое не стреляет по твоим собственным коленям. Но сперва давай разберемся, что вообще такое макросы в С и С++?

Что есть макросы?

В языках С и С++ есть такой механизм, как препроцессор. Он обрабатывает исходный код программы ДО того, как она будет скомпилированна. У перпроцессора есть свои директивы, такие как #include, #pragma, #if и тд. Но нам интересна только директива #define.

В языке Си довольно распространенной практикой является объявление глобальных констант с помощью директивы #define:

#define PI 3.14159

А потом, на этапе препроцессинга все использования PI будут заменены указанным значением:

double area = 2 * PI * r * r;

После препроцессинга, который по сути является банальной подстановкой, это выражение превратится в:

double area = 2 * 3.14159 * r * r;

PI - макрос, в самом простом его исполнении. Естественно, макросы в таком виде не работают как переменные. Им нельзя присваивать новое значение или использовать их адрес.

// Так нельзя:PI = 3; // после препроцессинга: 3.14159 = 3int *x = &PI;    // после препроцессинга: int *x = &3.14159

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

#undef PI

После этой строчки обращаться к PI будет уже нельзя.

Макросы с параметрами

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

#define MAX(a, b) a >= b ? a : b

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

#define SWAP(type, a, b) type tmp = a; a = b; b = tmp;

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

SWAP(int, num1, num2)SWAP(float, num1, num2)

Макросы так же можно записывать в несколько строк, но тогда каждая строка, кроме последней, должна заканчиваться символом '\':

#define SWAP(type, a, b) type tmp = a; \ a = b; \b = tmp;

Параметр макроса можно превратить в строку, добавив перед ним знак '#':

#define PRINT_VAL(val) printf("Value of %s is %d" #val, val);int = 5;PRINT_VAL(x)  // -> Value of x is 5

А еще параметр можно приклеить к чему-то еще, чтобы получился новый идентификатор. Для этого между параметром и тем, с чем пы его склеиваем, нужно поставить '##':

#define PRINT_VAL (number) printf("%d", value_##number);int value_one = 10, value_two = 20;PRINT_VAL(one)  // -> 10PRINT_VAL(two)  // -> 20

Техника безопасности при работе с макросами

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

1. Параметрами макросов не должны быть выражения и вызовы функций.

Ранее я уже объявлял макрос MAX. Но что получится, если попытаться вызвать его вот так:

int x = 1, y = 5;int max = MAX(++x, --y);

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

int max = ++x >= --y ? ++x : --y;

В итоге переменная max будет равна не 4, как мы ожидали, а 3. Потом можно уйму времени потратить, отлавливая эту ошибку. Так что в качестве аргумента макроса нужно всегда передавать уже конечное значение, а не какое-то выражение или вызов функции. Иначе выражение или функция будут вычислены столько раз, сколько используется этот параметр в теле макроса.

2. Все аргументы макроса и сам макрос должны быть заключены в скобки.

Это правило я уже нарушил при написании макроса MAX. Что получится, если мы захотим использовать этот макрос в составе какого-то математического выражения?

int result = 5 + MAX(1, 4);

По логике, переменная result должна будет иметь значение 9, однако вот что мы получаем в результате макроподстановки:

int result = 5 + 1 > 4 ? 1 : 4;

И переменная result внезапно примет значение 1. Чтобы такого не происходило, макрос MAX должен быть объявлен следующим образом:

#define MAX(a, b) ((a) >= (b) ? (a) : (b))

В таком случае все действия произойдут в нужном порядке.

3. Многострочные макросы должны иметь свою область видимости.

Например у нас есть макрос, который вызывает две функции:

#define MACRO() doSomething(); \ doSomethinElse();

А теперь попробуем использовать этот макрос в таком контексте:

if (some_condition) MACRO()

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

if (some_condition) doSomething();doSomethinElse();

Нетрудно заметить, что под действие if попадет только первая функция, а вторая будет вызываться всегда. Именно для того, чтобы избежать подобных багов, у макросов должна быть объявлена своя область видимости. Для удобства в этих целях принято использовать цикл do {} while (0); .

#define MACRO() do { \ doSomething(); \           doSomethinElse(); \         } while(0)

Поскольку в условии цикла стоит ноль, он отработает ровно один раз. Это делается, во первых, для того, чтобы у тела макроса появилась своя область видимости, ограниченная телом цикла, а во вторых, чтобы сделать вызов макроса более привычным, потому что теперь после MACRO() нужно будет ставить точку с запятой. Если бы мы просто ограничили тело макроса фигурными скобками, точку с запятой после его вызова поставить бы не получилось.

Еще немного примеров

В языке Си при помощи макросов можно эффективно избавляться от дублирования кода. Банальный пример - объявим несколько функций сложения для работы с разными типами данных:

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

DEF_SUM(int)DEF_SUM(float)DEF_SUM(double)int main() {     sum_int(1, 2);  sum_float(2.4, 6,3); sum_double(1.43434, 2,546656);}

Таким образом у нас получился аналог шаблонов из С++. Но стоит сразу обратить внимание, что данный способ не подойдет для типов, название которых состоит более чем из одного слова, например long long или unsigned short, потому что не получится нормально склеить название функции (sum_##type). Для этого сперва придется объявить для них новый тип, состоящий из одного слова.

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

Мой блог в Телеграме

Подробнее..
Категории: C++ , С++ , C , Си , Macro , Макросы , Define

Recovery mode Типы, где их не ждали

12.11.2020 08:22:24 | Автор: admin

Давайте представим себе реализацию модуля Scaffold, который генерирует структуру с предопределенными пользовательскими полями и инжектит ее в вызываемый модуль при помощи use Scaffold. При вызове use Scaffold, fields: foo: [custom_type()], ... мы хотим реализовать правильный тип в Consumer модуле (common_field в примере ниже определен в Scaffold или еще где-нибудь извне).


@type t :: %Consumer{  common_field: [atom()],  foo: [custom_type()],  ...}

Было бы круто, если бы мы могли точно сгенерировать тип Consumer.t() для дальнейшего использования и создать соответствующую документацию для пользователей нашего нового модуля.


Lighthouse in French Catalonia


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


defmodule Scaffold do  defmacro __using__(opts) do    quote do      @fields unquote(opts[:fields])      @type t :: %__MODULE__{        version: atom()        # magic      }      defstruct @fields    end  endenddefmodule Consumer do  use Scaffold, fields: [foo: integer(), bar: binary()]end

и, после компиляции:


defmodule Consumer do  @type t :: %Consumer{    version: atom(),    foo: integer(),    bar: binary()  }  defstruct ~w|version foo bar|aend

Выглядит несложно, да?


Наивный подход


Давайте начнем с анализа того, что за AST мы получим в Scaffold.__using__/1.


  defmacro __using__(opts) do    IO.inspect(opts)  end# [fields: [foo: {:integer, [line: 2], []},#            bar: {:binary, [line: 2], []}]]

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


  quote do    custom_types = unquote(opts[:fields])    ...  end# == Compilation error in file lib/consumer.ex ==#  ** (CompileError) lib/consumer.ex:2: undefined function integer/0

Бамс! Типыэто чего-то особенного, как говорят в районе Привоза; мы не можем просто взять и достать их из AST где попало. Может быть, unquote по месту сработает?


      @type t :: %__MODULE__{              unquote_splicing([{:version, atom()} | opts[:fields]])            }# == Compilation error in file lib/scaffold.ex ==#  ** (CompileError) lib/scaffold.ex:11: undefined function atom/0

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


Ладно, кажется, нам нужно собрать все это богатство в AST и инжектнуть его целиком, а не по частям, чтобы компилятор увидел сразу правильное объявление.


Построение типа в AST


Я опущу тут пересказ нескольких часов моих метаний, мучений, и тычков пальцем в небо. Все знают, что я пишу код в основном наугад, ожидая, что вдруг какая-нибудь комбинация этих строк скомпилируется и заработает. В общем, сложности тут с контекстом. Мы должны пропихнуть полученные определения полей в неизменном виде напрямую в макрос, объявляющий тип, ни разу не попытавшись это AST анквотнуть (потому что в момент unquote типы наподобие binary() будут немедленно приняты за обыкновенную функцию и убиты из базуки вызваны компилятором напрямую, приводя к CompileError.


Кроме того, мы не можем использовать обычные функции внутри quote do, потому что все содержимое блока, переданного в quote, уже само по себеAST.


quote do  Enum.map([:foo, :bar], & &1)end# {#   {:., [], [{:__aliases__, [alias: false], [:Enum]}, :map]}, [],#     [[:foo, :bar], {:&, [], [{:&, [], [1]}]}]}

Видите? Вместо вызова функции, мы получили ее препарированное AST, все эти Enum, :map, и прочий маловнятный мусор. Иными словами, нам придется создать AST определения типа вне блока quote и потом просто анквотнуть внутри него. Давайте попробуем.


Чуть менее наивная попытка


Итак, нам надо инжектнуть AST как AST, не пытаясь его анквотнуть. Звучит устрашающе? Вовсе нет, отнюдь.


defmacro __using__(opts) do  fields = opts[:fields]  keys = Keyword.keys(fields)  type = ???  quote location: :keep do    @type t :: unquote(type)    defstruct unquote(keys)  endend

Все, что нам нужно сделать сейчас, это произвести надлежащий AST, все остальное в порядке. Ну, пусть ruby сделает это за нас!


iex|1  quote do...|1    %Foo{version: atom(), foo: binary()}...|1  end#{:%, [],#   [#     {:__aliases__, [alias: false], [:Foo]},#     {:%{}, [], [version: {:atom, [], []}, foo: {:binary, [], []}]}#   ]}

А нельзя ли попроще?


iex|2  quote do...|2    %{__struct__: Foo, version: atom(), foo: binary()}...|2  end# {:%{}, [],#   [#     __struct__: {:__aliases__, [alias: false], [:Foo]},#     version: {:atom, [], []},#     foo: {:binary, [], []}#   ]}

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


Почти работающее решение


defmacro __using__(opts) do  fields = opts[:fields]  keys = Keyword.keys(fields)  type =    {:%{}, [],      [        {:__struct__, {:__MODULE__, [], ruby}},        {:version, {:atom, [], []}}        | fields      ]}  quote location: :keep do    @type t :: unquote(type)    defstruct unquote(keys)  endend

или, если нет цели пробросить типы из собственно Scaffold, даже проще (как мне вот тут подсказали: Qqwy here). Осторожно, оно не будет работать с проброшенными типами, version: atom() за пределами блока quote выбросит исключение.


defmacro __using__(opts) do  fields = opts[:fields]  keys = Keyword.keys(fields)  fields_with_struct_name = [__struct__: __CALLER__.module] ++ fields  quote location: :keep do    @type t :: %{unquote_splicing(fields_with_struct)}    defstruct unquote(keys)  endend

Вот что получится в результате генерации документации для целевого модуля (mix docs):


Screenshot of type definition


Примечание: трюк с фрагментом AST


Но что, если у нас уже есть сложный блок AST внутри нашего __using__/1 макроса, который использует значения в кавычках? Переписать тонну кода, чтобы в результате запутаться в бесконечной череде вызовов unquote изнутри quote? Это просто даже не всегда возможно, если мы хотим иметь доступ ко всему, что объявлено внутри целевого модуля. На наше счастье, существует способ попроще.


NB для краткости я покажу простое решение для объявления всех пользовательских полей, имеющих тип atom(), которое тривиально расширяеься до принятия любых типов из входных параметров, включая внешние, такие как GenServer.on_start() и ему подобные. Эту часть я оставлю для энтузиастов в виде домашнего задания.

Итак, нам надо сгенерировать тип внутри блока quote do, потому что мы не можем передавать туда-сюда atom() (оно взовется с CompileError, как я показал выше). Хначит, что-нибудь типа такого:


keys = Keyword.keys(fields)type =  {:%{}, [],    [      {:__struct__, {:__MODULE__, [], ruby}},      {:version, {:atom, [], []}}      | Enum.zip(keys, Stream.cycle([{:atom, [], []}]))    ]}

Это все хорошо, но как теперь добавить этот АСТ в декларацию @type? На помощь приходит очень удобная функция эликсира под названием Quoted Fragment, специально добавленный в язык ради генерации кода во время компиляциию Например:


defmodule Squares do  Enum.each(1..42, fn i ->    def unquote(:"squared_#{i}")(),      do: unquote(i) * unquote(i)  end)endSquares.squared_5# 25

Quoted Fragments автоматически распознаются компилятором внутри блоков quote, с напрямую переданным контекстом (bind_quoted:). Проще простого.


defmacro __using__(opts) do  keys = Keyword.keys(opts[:fields])  quote location: :keep, bind_quoted: [keys: keys] do    type =      {:%{}, [],        [          {:__struct__, {:__MODULE__, [], ruby}},          {:version, {:atom, [], []}}          | Enum.zip(keys, Stream.cycle([{:atom, [], []}]))        ]}    #              @type t :: unquote(type)    defstruct keys  endend

Одинокий вызов unquote/1 тут разрешен, потому что bind_quoted: был напрямую указан как первый аргумент в вызове quote/2.




Удачного внедрения!

Подробнее..
Категории: Open source , Elixir/phoenix , Erlang/otp , Injection , Macros , Macro

Recovery mode Типы в рантайме глубже в крольчью нору

19.11.2020 10:11:44 | Автор: admin

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


Все, что предложено по ссылке, будет работать для явных определений типа по месту использования, наподобие use Foo, var: type(). К сожалению, такой подход обречен, если мы хотим определить типы где-нибудь в другом месте: рядом в коде при помощи атрибутов модуля, или, там, в конфиге. Например, для определения структуры мы можем захотеть написать что-то типа такого:


# @fields [foo: 42]# defstruct @fields@definition var: atom()use Foo, @definition

Lighthouse in French Catalonia


Код выше не то, что не обработает тип так, как нам хочетсяон не соберется вовсе, потому что @definition var: atom() выбросит исключение ** (CompileError) undefined function atom/0.


Наивный подход


Один из моих самых любимых профессиональных афоризмов недели кодирования могут сэкономить вам часы планирования (обычно приписывается @tsilb, но пользователь был заблокирован супердемократичным твиттером, поэтому я не уверен.) Мне она так нравится, что я повторяю ее как мантру на каждом втором совещании, но, как это всегда бывает с незыблемыми жизненными принципами, часто не следую провозглашаемому сам.


Итак, я начал с того, что сделал две разных реализации __using__/1: одну, которая принимает список (и ожидает увидеть в нем пары field type()), и другую принимающую все, что угодно, ожидая встретить в аргументах либо квотированные типы, либо триплы {Module, :type, [params]}. Я использовал сигил ~q||, который был услужливо имплементирован мной же, в одном из стародавних игрушечных проектов, во времена, когда я учился работать с макросами и AST. Он позволяет вместо quote/1 писать лаконичнее: foo: ~q|atom()|. Там внутри я руками строил список, который потом передавался в первую функцию, принимающую списки. Весь этот код был настоящим кошмаром. Я сомневаюсь, что видел что-то более невнятное за всю свою карьеру, несмотря на то, что я чувствую себя абсолютно комфортно с регулярными выражениями, они мне нравятся, и я их часто использую. Однажды я выиграл спор на воспроизведение регулярного выражения для электронной почты максимально близко к оригиналу, но этот код, всего-то передававший туда-сюда старый добрый простой эрланговский тип оказался в пять раз запутаннее и как-то неаккуратнее, что ли.


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


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


Tyyppi


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


В ядре эликсира присутствует незадокументированный модуль Code.Typespec, который существенно облегчил мне жизнь. Я начал с очень простого подхода: с проверки всех возможных термов по всем возможным типам. Я просто загрузил все типы, доступные в моей текущей сессии, и дописывал новые обработчики по мере того, как рекурсивный анализ типов падал глубже по рекурсии. Честно говоря, это было скорее скучно, чем весело. Зато оно привело меня к первой полезной части этой библиотекифункции Tyyppi.of?/2, которая принимает тип и терм, а возвращает логическое значение да/нет в зависимости от того, принадлежит ли терм указанному типу.


iex|tyyppi|1  Tyyppi.of? GenServer.on_start(), {:ok, self()}# trueiex|tyyppi|2  Tyyppi.of? GenServer.on_start(), :ok# false

Мне нужно было какое-то внутреннее представление для типов, поэтому я решил хранить все в виде структуры с именем Tyyppi.T. Так у Tyyppi.of?/2 появился брат-близнец Tyyppi.of_type?/2.


iex|tyyppi|3  type = Tyyppi.parse(GenServer.on_start)iex|tyyppi|4  Tyyppi.of_type? type, {:ok, self()}# true

Единственный нюанс, связанный с этим подходом, заключается в том, что мне нужно загрузить и сохранить все типы, доступные в системе, и эта информация не будет доступна в релизах. На данный момент я прекрасно справляюсь с хранением всего этого в обычном файле при помощи :erlang.term_to_binary/1, который связывается с релизом и загружается через обычный специализированный Config.Provider.


Структуры


Теперь я был полностью вооружен, чтобы вернуться к своей первоначальной задаче: создать удобный способ объявления типизированной структуры. Со всем этим багажом на борту, это было легко. Я решил ограничить само объявление структуры явным встроенным литералом, содержащим пары key: type(). Также я реализовал для него Access, с проверкой типов при upserts. Имея все это под рукой, я решил позаимствовать еще пару идей у Ecto.Changeset и добавил перегружаемые функции cast_field/1 и validate/1.


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


defmodule MyStruct do  import Kernel, except: [defstruct: 1]  import Tyyppi.Struct, only: [defstruct: 1]  @typedoc "The user type defined before `defstruct/1` declaration"  @type my_type :: :ok | {:error, term()}  @defaults foo: :default,            bar: :erlang.list_to_pid('<0.0.0>'),            baz: {:error, :reason}  defstruct foo: atom(), bar: GenServer.on_start(), baz: my_type()  def cast_foo(atom) when is_atom(atom), do: atom  def cast_foo(binary) when is_binary(binary),    do: String.to_atom(binary)  def validate(%{foo: :default} = my_struct), do: {:ok, my_struct}  def validate(%{foo: foo} = my_struct), do: {:error, {:foo, foo}end

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


Весь код библиотеки доступен, как всегда, на гитхабе.




Удачного рантаймтайпинга!

Подробнее..
Категории: Open source , Elixir/phoenix , Erlang/otp , Injection , Macros , Macro

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru