Классы типов в Scala3: Руководство для начинающих | Леджер

Классы типов в Scala3: руководство для начинающих | Леджер

Исходный узел: 3028113

Этот документ предназначен для начинающего разработчика Scala3, который уже разбирается в Scala-прозе, но озадачен всеми `implicits` и параметризованные черты в коде.

В этом документе объясняется, почему, как, где и когда Типовые классы (TC).

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

Начнем с того, почему…

Проблема выражения

В 1998 Филип Уодлер заявил что «проблема выражения — это новое название старой проблемы». Это проблема расширяемости программного обеспечения. По мнению господина Вадлера, решение проблемы выражения должно соответствовать следующим правилам:

  • Правило 1: Разрешить реализацию существующее поведение (подумайте о черте Scala), которую нужно применить к новые представления (подумайте о классе случая)
  • Правило 2.  Разрешить реализацию новое поведение быть примененным к существующие представления
  • Правило 3: Это не должно ставить под угрозу тип безопасности
  • Правило 4: Не должно требоваться перекомпиляция существующий код

Решение этой проблемы и будет серебряной нитью этой статьи.

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

Любой объектно-ориентированный язык имеет встроенное решение для правила 1 с полиморфизм подтипа. Вы можете безопасно реализовать любой `trait` определено в зависимости от `class` в вашем собственном коде, без перекомпиляции зависимости. Давайте посмотрим на это в действии:

масштаб

def todo = 42
type Height = Int
type Block = Int

object Lib1:
  trait Blockchain:
    def getBlock(height: Height): Block

  case class Ethereum() extends Blockchain:
    override def getBlock(height: Height) = todo

  case class Bitcoin() extends Blockchain:
    override def getBlock(height: Height) = todo


object Lib2:
  import Lib1.*

  case class Polkadot() extends Blockchain:
    override def getBlock(height: Height): Block = todo

val eth = Lib1.Ethereum()
val btc = Lib1.Bitcoin()
val dot = Lib2.Polkadot()

В этом вымышленном примере библиотека `Lib1` (строка 5) определяет черту `Blockchain` (строка 6) с двумя его реализациями (строки 2 и 9). `Lib1` останется неизменным во ВСЕХ документах (применение правила 4).

`Lib2` (строка 15) реализует существующее поведение `Blockchain`в новом классе`Polkadot` (правило 1) безопасным для типа способом (правило 3), без перекомпиляции `Lib1` (правило 4). 

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

Давайте представим, что в `Lib2`мы хотим нового поведения`lastBlock`будет реализовано специально для каждого`Blockchain`.

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

масштаб

def todo = 42
type Height = Int
type Block = Int

object Lib1:
  trait Blockchain:
    def getBlock(height: Height): Block

  case class Ethereum() extends Blockchain:
    override def getBlock(height: Height) = todo

  case class Bitcoin() extends Blockchain:
    override def getBlock(height: Height) = todo

object Lib2:
  import Lib1.*

  case class Polkadot() extends Blockchain:
    override def getBlock(height: Height): Block = todo

  def lastBlock(blockchain: Blockchain): Block = blockchain match
      case _:Ethereum => todo
      case _:Bitcoin  => todo
      case _:Polkadot => todo
  

object Lib3:
  import Lib1.*

  case class Polygon() extends Blockchain:
    override def getBlock(height: Height): Block = todo

import Lib1.*, Lib2.*, Lib3.*
println(lastBlock(Bitcoin()))
println(lastBlock(Ethereum()))
println(lastBlock(Polkadot()))
println(lastBlock(Polygon()))

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

`Lib1` остается нетронутым (помните, что во всем этом документе применяется правило 4). 

Решение, реализованное в `Lib2` есть нормально пока в `Lib3`. Это нарушает правило безопасности типов (правило 3), поскольку этот код дает сбой во время выполнения в строке 37. И изменение `Lib2` нарушит правило 4.

Другое решение — использовать `extension`.

масштаб

def todo = 42
type Height = Int
type Block = Int

object Lib1:
  trait Blockchain:
    def getBlock(height: Height): Block

  case class Ethereum() extends Blockchain:
    override def getBlock(height: Height) = todo

  case class Bitcoin() extends Blockchain:
    override def getBlock(height: Height) = todo

object Lib2:
  import Lib1.*

  case class Polkadot() extends Blockchain:
    override def getBlock(height: Height): Block = todo

    def lastBlock(): Block = todo

  extension (eth: Ethereum) def lastBlock(): Block = todo

  extension (btc: Bitcoin) def lastBlock(): Block = todo

import Lib1.*, Lib2.*
println(Bitcoin().lastBlock())
println(Ethereum().lastBlock())
println(Polkadot().lastBlock())

def polymorphic(blockchain: Blockchain) =
  // blockchain.lastBlock()
  ???

`Lib1` остается нетронутым (применение правила 4 во всем документе). 

`Lib2` определяет поведение для своего типа (строка 21) и `расширение` для существующих типов (строки 23 и 25).

Строки 28–30: новое поведение можно использовать в каждом классе. 

Но нет возможности назвать это новое поведение полиморфно (строка 32). Любая попытка сделать это приводит к ошибкам компиляции (строка 33) или к переключениям на основе типов. 

Это правило № 2 сложное. Мы попытались реализовать это с помощью нашего собственного определения полиморфизма и трюка с «расширением». И это было странно.

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

Шаблон класса типа

Рецепт шаблона класса типа (сокращенно TC) состоит из 3 шагов. 

  1. Определите новое поведение
  2. Реализуйте поведение
  3. Используйте поведение

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

1. Определите новое поведение
масштаб

object Lib2:
  import Lib1.*

  trait LastBlock[A]:
    def lastBlock(instance: A): Block

`Lib1` снова остается нетронутым.

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

Параметр `A` представляет тип, к которому мы хотим применить поведение, который является подтипом `Blockchain`в нашем случае.

Некоторые замечания:

  • При необходимости параметризованный тип `A` может быть дополнительно ограничен системой типов Scala. Например, мы могли бы применить `A`быть `Blockchain`. 
  • Кроме того, в TC может быть объявлено гораздо больше функций.
  • Наконец, каждая функция может иметь еще множество произвольных параметров.

Но давайте упростим ситуацию ради удобства чтения.

2. Реализуйте поведение
масштаб

object Lib2:
  import Lib1.*

  trait LastBlock[A]:
    def lastBlock(instance: A): Block

  val ethereumLastBlock = new LastBlock[Ethereum]:
    def lastBlock(eth: Ethereum) = eth.lastBlock

  val bitcoinLastBlock = new LastBlock[Bitcoin]:
    def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

Для каждого типа новый `LastBlock` ожидается поведение, существует конкретный экземпляр такого поведения. 

`Ethereum` строка реализации 22 вычисляется из `eth`экземпляр передан как параметр. 

Реализация `LastBlock`для`Bitcoin`строка 25 реализована с неуправляемым вводом-выводом и не использует его параметр.

Итак, `Lib2` реализует новое поведение`LastBlock`для`Lib1`классы.

3. Используйте поведение
масштаб

object Lib2:
  import Lib1.*

  trait LastBlock[A]:
    def lastBlock(instance: A): Block

  val ethereumLastBlock = new LastBlock[Ethereum]:
    def lastBlock(eth: Ethereum) = eth.lastBlock

  val bitcoinLastBlock = new LastBlock[Bitcoin]:
    def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

import Lib1.*, Lib2.*

def useLastBlock[A](instance: A, behavior: LastBlock[A]) =
  behavior.lastBlock(instance)

println(useLastBlock(Ethereum(lastBlock = 2), ethereumLastBlock))
println(useLastBlock(Bitcoin(), bitcoinLastBlock))

Строка 30 `useLastBlock` использует экземпляр `A` и `LastBlock` поведение, определенное для этого экземпляра.

Строка 33 `useLastBlock` вызывается с экземпляром `Ethereum` и реализация `LastBlock` определено в `Lib2`. Обратите внимание, что можно передать любую альтернативную реализацию `LastBlock[A]` (подумайте о внедрение зависимости).

`useLastBlock` является связующим звеном между представлением (действительным A) и его поведением. Данные и поведение разделены, и это то, за что выступает функциональное программирование.

Обсуждение

Давайте вспомним правила задачи выражения:

  • Правило 1: Разрешить реализацию существующее поведение  быть примененным к новые классы
  • Правило 2.  Разрешить реализацию новое поведение быть примененным к существующие классы
  • Правило 3: Это не должно ставить под угрозу тип безопасности
  • Правило 4: Не должно требоваться перекомпиляция существующий код

Правило 1 можно решить «из коробки» с помощью полиморфизма подтипов.

Только что представленный шаблон TC (см. предыдущий снимок экрана) решает правило 2. Он типобезопасен (правило 3), и мы никогда не трогали `Lib1` (правило 4). 

Однако использовать его нецелесообразно по нескольким причинам:

  • В строках 33–34 мы должны явно передать поведение экземпляру. Это дополнительные накладные расходы. Нам нужно просто написать `useLastBlock(Bitcoin())`.
  • В строке 31 синтаксис необычный. Мы бы предпочли написать краткую и более объектно-ориентированнуюinstance.lastBlock()`заявление.

Давайте выделим некоторые возможности Scala для практического использования TC. 

Улучшенный опыт разработчика

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

Последствия

Неявная область — это специальная область, определяемая во время компиляции, в которой может существовать только один экземпляр данного типа. 

Программа помещает экземпляр в неявную область видимости с помощью `given`ключевое слово. В качестве альтернативы программа может получить экземпляр из неявной области видимости с помощью ключевого слова `using`.

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

Он отличается от глобальной области видимости, потому что: 

  1. Это решается контекстно. Два места программы могут использовать экземпляр одного и того же типа в неявной области действия, но эти два экземпляра могут быть разными.
  2. За сценой код передает функцию неявных аргументов функции до тех пор, пока не будет достигнуто неявное использование. Он не использует глобальное пространство памяти.

Возвращаемся к классу шрифтов! Давайте возьмем тот же пример.

масштаб

def todo = 42
type Height = Int
type Block = Int
def http(uri: String): Block = todo

object Lib1:
  trait Blockchain:
    def getBlock(height: Height): Block

  case class Ethereum() extends Blockchain:
    override def getBlock(height: Height) = todo

  case class Bitcoin() extends Blockchain:
    override def getBlock(height: Height) = todo

`Lib1` — это тот же немодифицированный код, который мы определили ранее. 

масштаб

object Lib2:
  import Lib1.*

  trait LastBlock[A]:
    def lastBlock(instance: A): Block

  given ethereumLastBlock:LastBlock[Ethereum] = new LastBlock[Ethereum]:
    def lastBlock(eth: Ethereum) = eth.lastBlock

  given bitcoinLastBlock:LastBlock[Bitcoin] = new LastBlock[Bitcoin]:
    def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

import Lib1.*, Lib2.*

def useLastBlock[A](instance: A)(using behavior: LastBlock[A]) =
  behavior.lastBlock(instance)

println(useLastBlock(Ethereum(lastBlock = 2)))
println(useLastBlock(Bitcoin()))

Строка 19 — новое поведение `LastBlock` определяется точно так же, как мы это делали ранее.

Строка 22 и строка 25, `val` заменяется на `given`. Обе реализации `LastBlock` помещаются в неявную область видимости.

Строка 31 `useLastBlock`объявляет поведение`LastBlock` как неявный параметр. Компилятор разрешает соответствующий экземпляр `LastBlock`из неявной области видимости, контекстуализированной из местоположений вызывающего объекта (строки 33 и 34). Строка 28 импортирует все из `Lib2`, включая неявную область видимости. Итак, компилятор передает экземпляры, определенные в строках 22 и 25, в качестве последнего параметра `useLastBlock`. 

Пользователю библиотеки использовать класс типов стало проще, чем раньше. Строки 34 и 35 разработчику нужно только убедиться, что экземпляр поведения внедрен в неявную область видимости (и это может быть просто `import`). Если неявное не `given`где код`using` это, говорит ему компилятор.

Неявные методы Scala облегчают задачу передачи экземпляров классов вместе с экземплярами их поведения.

Неявные сахара

Строки 22 и 25 предыдущего кода могут быть улучшены! Давайте перейдем к реализации TC.

масштаб

given LastBlock[Ethereum] = new LastBlock[Ethereum]:
    def lastBlock(eth: Ethereum) = eth.lastBlock

  given LastBlock[Bitcoin] = new LastBlock[Bitcoin]:
    def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

Строки 22 и 25, если имя экземпляра не используется, его можно опустить.

масштаб


  given LastBlock[Ethereum] with
    def lastBlock(eth: Ethereum) = eth.lastBlock

  given LastBlock[Bitcoin] with
    def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

В строках 22 и 25 повторение типа можно заменить на `with`ключевое слово.

масштаб

given LastBlock[Ethereum] = _.lastBlock

  given LastBlock[Bitcoin] = _ => http("http://bitcoin/last")

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

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

Расширение

При разумном использовании `extension`механизм может упростить синтаксис использования класса типа.

масштаб

object Lib2:
  import Lib1.*

  trait LastBlock[A]:
    def lastBlock(instance: A): Block

  given LastBlock[Ethereum] with
    def lastBlock(eth: Ethereum) = eth.lastBlock

  given LastBlock[Bitcoin] with
    def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

  extension[A](instance: A)
    def lastBlock(using tc: LastBlock[A]) = tc.lastBlock(instance)

import Lib1.*, Lib2.*

println(Ethereum(lastBlock = 2).lastBlock)
println(Bitcoin().lastBlock)

Строки 28-29 — общий метод расширения `lastBlock` определяется для любого `A`с `LastBlock` Параметр TC в неявной области действия.

В строках 33–34 расширение использует объектно-ориентированный синтаксис для использования TC.

масштаб

object Lib2:
  import Lib1.*

  trait LastBlock[A]:
    def lastBlock(instance: A): Block

  given LastBlock[Ethereum] with
    def lastBlock(eth: Ethereum) = eth.lastBlock

  given LastBlock[Bitcoin] with
    def lastBlock(btc: Bitcoin) = http("http://bitcoin/last")

  extension[A](instance: A)(using tc: LastBlock[A])
    def lastBlock = tc.lastBlock(instance)
    def penultimateBlock = tc.lastBlock(instance) - 1

import Lib1.*, Lib2.*

val eth = Ethereum(lastBlock = 2)
println(eth.lastBlock)
println(eth.penultimateBlock)

val btc = Bitcoin()
println(btc.lastBlock)
println(btc.penultimateBlock)

В строке 28 параметр TC также можно определить для всего расширения, чтобы избежать повторения. В строке 30 мы повторно используем TC в расширении, чтобы определить `penultimateBlock` (хотя это может быть реализовано на `LastBlock`черта напрямую)

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

Общий тип с TC
масштаб

import Lib1.*, Lib2.*

def useLastBlock1[A](instance: A)(using LastBlock[A]) = instance.lastBlock

def useLastBlock2[A: LastBlock](instance: A) = instance.lastBlock

val eth = Ethereum(lastBlock = 2)
assert(useLastBlock1(eth) == useLastBlock2(eth))

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

Шаблон TC настолько широко используется, что существует синтаксис общего типа для выражения «типа с неявным поведением». Синтаксис строки 36 представляет собой более краткую альтернативу предыдущему (строка 34). Это позволяет избежать специального объявления безымянного неявного параметра TC.

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

Автоматический вывод

Многие библиотеки Scala используют TC, оставляя программисту возможность реализовать их в своей базе кода.

Например, Circe (библиотека десериализации json) использует TC `Encoder[T]`и`Decoder[T]` чтобы программисты могли реализовать их в своей кодовой базе. После реализации можно использовать весь объем библиотеки. 

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

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

Под капотом во время компиляции происходит самоанализ общих макросов. Типы как чистую структуру данных и генерировать TC[T] для пользователей библиотеки. 

Получение TC в общем виде очень распространено, поэтому Scala представила для этой цели полный набор инструментов. Этот метод не всегда рекламируется в документации библиотеки, хотя это способ использования вывода в Scala 3.

масштаб

object GenericLib:

  trait Named[A]:
    def blockchainName(instance: A): String

  object Named:
    import scala.deriving.*

    inline final def derived[A](using inline m: Mirror.Of[A]): Named[A] =
      val nameOfType: String = inline m match
        case p: Mirror.ProductOf[A] => compiletime.constValue[p.MirroredLabel]
        case _ => compiletime.error("Not a product")
      new Named[A]:
        override def blockchainName(instance: A):String = nameOfType.toLowerCase

  extension[A] (instance: A)(using tc: Named[A])
    def blockchainName = tc.blockchainName(instance)

import Lib1.*, GenericLib.*

case class Polkadot() derives Named
given Named[Bitcoin] = Named.derived
given Named[Ethereum] = Named.derived

println(Ethereum(lastBlock = 2).blockchainName)
println(Bitcoin().blockchainName)
println(Polkadot().blockchainName)

Линия 18 новый ТК `Named` представлен. Этот ТК, строго говоря, не имеет отношения к блокчейн-бизнесу. Его цель — назвать блокчейн на основе имени кейс-класса.

Сначала сосредоточьтесь на строках определений 36-38. Существует два синтаксиса получения TC:

  1. В строке 36 экземпляр TC можно определить непосредственно в классе Case с помощью `derives`ключевое слово. Под капотом компилятор генерирует заданный `Named`экземпляр в`Polkadot` сопутствующий объект.
  2. В строках 37 и 38 экземпляры классов типов задаются для уже существующих классов с помощью `TC.derived

В строке 31 определяется общее расширение (см. предыдущие разделы) и `blockchainName` используется естественно.  

`derives` Ключевое слово ожидает метод в форме `inline def derived[T](using Mirror.Of[T]): TC[T] = ???` который определен в строке 24. Я не буду подробно объяснять, что делает этот код. В общих чертах:

  • `inline def` определяет макрос
  • `Mirror` является частью набора инструментов для анализа типов. Существуют разные виды зеркал, и в строке 26 кода основное внимание уделяется `Product`зеркала (класс корпуса — товар). Строка 27, если программисты попытаются получить что-то, что не является `Product`, код не скомпилируется.
  • `Mirror` содержит другие типы. Один из них, `MirrorLabel`, — это строка, содержащая имя типа. Это значение используется в строке 29 реализации `Named` ТС.

Авторы TC могут использовать метапрограммирование для предоставления функций, которые в общем генерируют экземпляры TC заданного типа. Программисты могут использовать специальный библиотечный API или инструменты разработки Scala для создания экземпляров своего кода.

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

Краткое изложение всех преимуществ

  • Это решает проблему выражения
    • Новые типы могут реализовать существующее поведение посредством традиционного наследования признаков.
    • Новое поведение может быть реализовано в существующих типах.
  • Разделение озабоченности
    • Код не поврежден и легко удаляется. TC разделяет данные и поведение, что является девизом функционального программирования.
  • Это безопасно
    • Он типобезопасен, поскольку не опирается на самоанализ. Это позволяет избежать большого сопоставления шаблонов с участием типов. если вы столкнетесь с написанием такого кода, вы можете обнаружить случай, когда шаблон TC подойдет идеально.
    • Неявный механизм безопасен при компиляции! Если экземпляр отсутствует во время компиляции, код не будет компилироваться. Ничего удивительного во время выполнения.
  • Это приносит специальный полиморфизм
    • Специальный полиморфизм обычно отсутствует в традиционном объектно-ориентированном программировании.
    • Благодаря специальному полиморфизму разработчики могут реализовать одно и то же поведение для различных несвязанных типов без использования традиционной подтипизации (которая объединяет код).
  • Внедрение зависимостей стало проще
    • Экземпляр TC может быть изменен по принципу подстановки Лискова. 
    • Если компонент зависит от TC, макет TC можно легко внедрить в целях тестирования. 

Показания счетчика

Каждый молоток предназначен для решения целого ряда задач.

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

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

Например, в ядре Scala есть `Numeric`класс типа:

масштаб

trait Numeric[T] extends Ordering[T] {
  def plus(x: T, y: T): T
  def minus(x: T, y: T): T
  def times(x: T, y: T): T

Действительно имеет смысл использовать такой класс типов, поскольку он позволяет повторно использовать алгебраические алгоритмы не только для типов, встроенных в Scala (Int, BigInt, …), но также и для пользовательских типов (`ComplexNumber`например).

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

  • API коллекции должен быть полным и стабильным. Он раскрывает общее поведение через черты, унаследованные реализациями. Высокая расширяемость здесь не является конкретной целью.
  • Он должен быть простым в использовании. TC добавляет умственную нагрузку конечному программисту-пользователю.
  • TC также может повлечь за собой небольшие накладные расходы на производительность. Это может быть критично для API коллекции.
  • Тем не менее, API коллекции по-прежнему расширяется за счет нового TC, определенного сторонними библиотеками.

Заключение

Мы увидели, что TC — это простой шаблон, который решает большую проблему. Благодаря богатому синтаксису Scala шаблон TC можно реализовать и использовать разными способами. Шаблон TC соответствует парадигме функционального программирования и является прекрасным инструментом для создания чистой архитектуры. Универсального решения не существует, и шаблон TC необходимо применять, когда он подходит.

Надеюсь, вы получили знания, прочитав этот документ. 

Код доступен на https://github.com/jprudent/type-class-article. Пожалуйста, свяжитесь со мной, если у вас есть какие-либо вопросы или замечания. Если хотите, вы можете использовать задачи или комментарии к коду в репозитории.


Джером ПРУДЕНТ

Инженер-программист

Отметка времени:

Больше от Ledger