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

Функциональное программирование в Swift. Начало

Время на прочтение15 мин
Количество просмотров26K
Автор оригинала: Chris Eidhof


Предисловие переводчика.


Отмечая окончание 2014 года, известная Swift группа SLUG из Сан-Франциско выбрала 5 наиболее популярных Swift видео за 2014 с организованных ею встреч. И среди них оказалось выступление Chris Eidhof «Функциональное программирование в Swift».
Сейчас Chris Eidhof — известная личность в Swift сообществе, он — автор недавно вышедшей книги «Functional programming in Swift», один из создателей журнала objc.io, организатор конференции «Functional Swift Conference», прошедшей 6-го декабря в Бруклине и будущей конференции UIKonf.
Но я открыла его, когда он, один из первых, опубликовал очень простую элегантную статью об эффективности функционального подхода в Swift к JSON парсингу.
В этой статье нет недоступных для понимания концепций, никаких мистических математических «химер» типа «Монада, Функтор, Аппликативный функтор», на которых Haskell программисты клянутся перед оставшимся миром, закатывая глаза.
Там нет и таких нововведений Swift, как дженерики (generics) и «вывод типа» (type inference).
Если вы хотите плавно «въехать» в функциональное программирование в Swift, то вы должны познакомиться с его статьей «Parsing JSON in Swift» и выступлением на SLUG «Functional Programming in Swift».

Для того, чтобы облегчить эту задачу, я представлю перевод статьи и ту часть выступления Chris Eidhof, которая касается парсинга JSON и которая собрала наибольшее количество вопросов на этом выступлении.
Кстати, к изучению «Монад, Функторов, Аппликативных функторов» вы безусловно прийдете и, по-видимому, именно они составят функциональное будущее Swift, но это — потом, а пока 2 пользовательских оператора и их хитроумная комбинация дают нам необходимый результат.

Итак, Крис взял пример JSON данных из постов скептически настроенных коллег Brent и David:

var json : [String: AnyObject] = [
  "stat": "ok",
  "blogs": [
    "blog": [
      [
        "id" : 73,
        "name" : "Bloxus test",
        "needspassword" : true,
        "url" : "http://remote.bloxus.com/"
      ],
      [
        "id" : 74,
        "name" : "Manila Test",
        "needspassword" : false,
        "url" : "http://flickrtest1.userland.com/"
      ]
    ]
  ]
]


и поставил задачу преобразовать эти JSON данные типизированным и безопасным способом в массив Swift структур:

struct Blog {
    let id: Int
    let name: String
    let needsPassword : Bool
    let url: NSURL
}


Перевод статьи «Парсинг JSON данных в Swift».


Во-первых, я покажу окончательные функции парсинга, которые содержат два оператора: >>= и <*>. (Примечание переводчика. В коде на Github вместо оператора >>=, используется оператор >>>=, так как оператор >>= в Swift уже занят и используется для побитого сдвига.)
Эти операторы выглядят в Swift немного странно, но с их помощью полный парсинг JSON структуры становится очень простым.
Остаток статьи будет посвящен описанию кода библиотеки.
Представленный ниже парсинг работает так, что если структура JSON — неправильная (например, отсутствует name или id — не целое число), то результат равен nil.
В нашем случае не требуется ни reflection, ни KVO, мы просто имеем пару простых функций и некоторый хитроумный способ их комбинации:

func parseBlog(blog: AnyObject) -> Blog? {
    return asDict(blog) >>= {
        mkBlog <*> int($0,"id")
               <*> string($0,"name")
               <*> bool($0,"needspassword")
               <*> (string($0, "url") >>= toURL)
    }
}

let parsed : [Blog]? = dictionary(json, "blogs") >>= {
    array($0, "blog") >>= {
        join($0.map(parseBlog))
    }
}


Что делает вышеприведенный код?
Давайте пройдемся по наиболее важным функциям.
Посмотрим на функцию dictionary. Эта функция получает на вход словарь input: [String: AnyObject] и определенный ключ key: Sting, и пытается найти в первоначальном словаре input по ключу key также словарь, который и будет возвращаемым Optional значением:

func dictionary(input: [String: AnyObject], key: String) ->  [String: AnyObject]? {
    return input[key] >>= { $0 as? [String:AnyObject] }
}


Например, в приведенном выше примере JSON данных, мы ожидаем, что по ключу "blogs" находится словарь. Если словарь существует, то мы его возвращаем, в противном случае возвращаем nil. Мы можем написать подобные функции для массивов (array), строк (strings) и целых чисел (int) ( здесь представлена сигнатура только для этих типов, а полный код находится на GitHub ):

func array(input: [String:AnyObject], key: String) ->  [AnyObject]?
func string(input: [String:AnyObject], key: String) -> String?
func int(input: [NSObject:AnyObject], key: String) -> Int?


Теперь давайте посмотрим на самую внешнюю структуру наших JSON данных. Это словарь, который присутствует в структуре под ключом "blogs". А под ключом "blog" содержится массив. Для такого парсинга мы можем написать следующий код:

if let blogsDict = dictionary(parsedJSON, "blogs") {
    if let blogsArray = array(blogsDict, "blog") {
         // Делайте что-то с массивом блогов
    }
}


Вместо этого мы определим оператор >>=, который берет Optional значение и применяет к нему функцию f только, если это Optional не nil. Это заставляет нас использовать «выравнивающую» функцию flatten, которая убирает ( «выравнивает») вложенные Optional и оставляет единственное Optional.

infix operator  >>= {}
 
func >>= <A,B> (optional : A?, f : A -> B?) -> B? {
    return flatten(optional.map(f))
}

func flatten<A>(x: A??) -> A? {
    if let y = x { return y }
    return nil
}


Другой оператор, который будет очень интенсивно использоваться, — это оператор <*> . Для парсинга единственного блога (структура Blog), у нас будет следующий код:

mkBlog <*> int(dict,"id")
       <*> string(dict,"name")
       <*> bool(dict,"needspassword")
       <*> (string(dict, "url") >>= toURL
)

Вы можете прочитать это как функциональный вызов, который становится исполняемым только, если все Optional значения не nil:

mkBlog(int(dict,"id"), string(dict,"name"), bool(dict,"needspassword"), (string(dict, "url") >>= toURL))


Давайте посмотрим на определение оператора <*> . Он комбинирует два Optional значения: в качестве левого операнда он берет функцию, а в качестве правого операнда — параметр этой функции. Он проверяет, чтобы оба операнда были не nil, и только тогда применяет функцию.

infix operator  <*> { associativity left precedence 150 }
func <*><A, B>(l: (A -> B)?, r: A?) -> B? {
    if let l1 = l {
        if let r1 = r {
            return l1(r1)
        }
    }
    return nil
}


А что такое mkBlog? Это каррированная функция (curried function), которая «оборачивает» наш инициализатор.
Во-первых, мы создаем функцию типа (Int, String, Bool, NSURL) -> Blog.
Затем функция curry преобразует ее в функцию типа Int -> String -> Bool -> NSURL -> Blog:

let mkBlog = curry {id, name, needsPassword, url in
   Blog(id: id, name: name, needsPassword: needsPassword, url: url)
}


Это необходимо, чтобы мы могли использовать mkBlog вместе с оператором <*>.
Давайте посмотрим на первую строку кода:

// mkBlog : Int -> String -> Bool -> NSURL -> Blog
// int(dict,"id") : Int?
let step1 = mkBlog <*> int(dict,"id")


Мы видим, что комбинация mkBlog и int (dict,"id") с помощью оператора <*> дает нам новую функцию типа (String -> Bool -> NSURL -> Blog)?. И если мы скомбинируем ее со строкой:

let step2 = step1 <*> string(dict,"name")


Мы получим функцию типа (Bool -> NSURL -> Blog)?. А если мы и дальше продолжим это делать, то закончим Optional значением Blog?.

Я надеюсь, что вам понятно, как все эти кусочки складываются друг с другом. Путем создания небольшого количества вспомогательных функций и операторов, мы сможем сделать строго типизованный парсинг JSON действительно очень простым. Вместо Optional, вы могли бы также использовать другой тип, который включает ошибки (errors), но это уже тема для другого поста.

Обо всех этих вещах мы пишем в нашей книге более подробно. Если вас это заинтересовало, то вы уже сейчас можете получить доступ к книге Functional Programming in Swift.

Примечание переводчика.


Код к статье Chris Eidhof «JSON parsing in Swift» с улучшенной функцией печати на Playground на Github.
Хотя Крис сказал, что понятно, как это все вместе взаимодействует, на самом деле не совсем так, и именно поэтому я привожу перевод его выступления на SLUG встрече, где он подробно показывает, как прийти к нужному результату.

Перевод выступления «Функциональное программирование в Swift».


Это отрывок из выступления Chris Eidhof на встрече в Сан-Франциско, в котором он рассказал о возможностях функционального программирования в Swift, но не как о замещении объектно-ориентированного программирования (OOP), а как о дополнительном инструменте разработки приложений.
Я привожу перевод только той части, которая относится к парсингу JSON, и ответы на вопросы.

Почему функциональное программирование?


Существует множество путей решения проблем помимо объектно-ориентированного программирования (OOP). Вы уже знаете, как решать проблемы с помощью OOP, но теперь Swift предлагает еще и очень легкое и удобное функциональное программирование. В действительности, некоторые задачи даже легче решать, используя функциональное программирование!

Одна из этих задач — парсинг JSON


Это преобразование нетипизованных JSON словарей в правильные, типизованные словари.
Рассматриваются JSON данные и ставится та же задача преобразования их в массив блогов [Blog], что и в вышеприведенной статье.

Первая попытка


Первая версия функции parseBlog возвращает Blog, если типы всех компонентов правильные.
Вложенные if продолжаются выполняться для каждого ключа и соответствующего типа только, если условия выполнены.
Если все условия выполняются, мы можем сконструировать значение Blog с корректными типами и значениями из Optionals значений.

func parseBlog(blogDict: [String:AnyObject]) -> Blog? { 
  if let id = blogDict["id"] as NSNumber? { 
    if let name = blogDict["name"] as NSString? { 
      if let needsPassword = blogDict["needspassword"] as NSNumber? { 
        if let url = blogDict["url"] as NSString? { 
          return Blog(id: id.integerValue,
                      name: name,
                      needsPassword: needsPassword.boolValue,
                      url: NSURL(string: url)
                      )
        }
      }
    }
  }
  return nil
}


Первое изменение состоит в том, чтобы создать функцию string, которая проверяет, имеет ли значение тип NSString, потому что в нашем случае мы это использовали дважды. Эта функция берет словарь, ищет ключ key и возвращает соответствующее значение только в случае, если ключу соответствует строка. В противном случае возвращается nil.

func string(input: [String:AnyObject], key: String) -> String? { 
  let result = input[key] 
  return result as String? 
}


Вторая попытка


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

func parseBlog(blogDict: [String:AnyObject]) -> Blog? { 
  if let id = blogDict["id"] as NSNumber? { 
    if let name = string(blogDict, "name") { 
      if let needsPassword = blogDict["needspassword"] as NSNumber? { 
        if let url = string(blogDict, "url") { 
          return Blog(id: id.integerValue,
                      name: name,
                      needsPassword: needsPassword.boolValue,
                      url: NSURL(string: url)
                      )
        }
      }
    }
  }
  return nil
}


Другие изменения в коде связаны с тем, что для numbers мы создадим функцию, аналогичную функции string. Эта функция ищет number и делает кастинг, если оно существует. Мы можем создать подобные функции для типов int и bool. Для Optionals мы также можем использовать map, которая исполняется только, если значение существует.

func number(input: [NSObject:AnyObject], key: String) -> NSNumber? { 
  let result = input[key] return result as NSNumber?
}

func int(input: [NSObject:AnyObject], key: String) -> Int? { 
  return number(input,key).map { $0.integerValue } 
}

func bool(input: [NSObject:AnyObject], key: String) -> Bool? { 
  return number(input,key).map { $0.boolValue }
}


Третья попытка


Рефакторизованный нами код теперь выглядит немного более декларативно:

func parseBlog(blogDict: [String:AnyObject]) -> Blog? { 
  if let id = int(blogDict, "id") { 
    if let name = string(blogDict, "name") { 
      if let needsPassword = bool(blogDict, "needspassword") { 
        if let url = string(blogDict, "url") { 
          return Blog(id: id,
                      name: name,
                      needsPassword: needsPassword,
                      url: NSURL(string: url)
                      )
        }
      }
    }
  }
  return nil
}


Мы можем продолжать улучшать наш код, пытаясь избавиться от вложенных if предложений. Наша функция flatten проверяет, все ли Optionals имеют значения, и если это так, то создается такой «большой» Optional в виде кортежа (tuple).

func flatten<A,B,C,D>(oa: A?,ob: B?,oc: C?,od: D?) -> (A,B,C,D)? { 
  if let a = oa { 
    if let b = ob { 
      if let c = oc { 
        if let d = od { 
          return (a,b,c,d)
        }
      }
    }
  }
  return nil
}



Четвертая попытка


Итак, мы вычисляем наши 4 переменных, «выравниваем» их с помощью функции flatten и если все они не nil, возвращаем Blog.

func parseBlog(blogData: [String:AnyObject]) -> Blog? { 
  let id = int(blogData,"id") 
  let name = string(blogData,"name") 
  let needsPassword = bool(blogData,"needspassword") 
  let url = string(blogData,"url").map { NSURL(string:$0) } 
  if let (id, name, needsPassword, url) = flatten(id, name, needsPassword, url) { 
    return Blog(id: id, name: name, needsPassword: needsPassword, url: url) 
  }
  return nil
}


Мы можем продолжить работать над нашим кодом, пытаясь избавиться от последнего if предложения. Это потребует функции A,B,C,D -> R, которая преобразует аргументы A,B,C,D в R , а также кортеж (A, B, C, D), и если они оба не nil, то функция применяется к кортежу.

func apply<A, B, C, D, R>(l: ((A,B,C,D) -> R)?, r: (A,B,C,D)?) -> R? { 
  if let l1 = l { 
    if let r1 = r { 
      return l1(r1)
    }
  }
  return nil
}


Пятая попытка


Теперь наш код выглядит так:

func parseBlog(blogData: [String:AnyObject]) -> Blog? { 
  let id = int(blogData,"id") 
  let name = string(blogData,"name") 
  let needsPassword = bool(blogData,"needspassword") 
  let url = string(blogData,"url").map { NSURL(string:$0) } 
  let makeBlog = { Blog(id: $0, name: $1, needsPassword: $2, url: $3) } 
  return apply(makeBlog, flatten(id, name, needsPassword, url)) }


Мы можем вызвать функцию apply с аргументом, которым является наша «выравненная» структура. Но для продолжения рефакторизации кода, нужно сделать функцию apply более обобщенной, а также выполнить каррирование.

func apply<A, R>(l: (A -> R)?, r: A?) -> R? { 
  if let l1 = l { 
    if let r1 = r { 
      return l1(r1)
    }
  }
  return nil
}


Может быть трудно с первого взгляда понять каррирование как концепцию. Она возвращает вложенные функции, которые могут быть очень полезны при функциональном программировании. Swift позволяет нам опускать круглые скобки при определении типа для вложенных функций. Повторно вызывая функцию apply, мы можем сделать код более компактным и наконец получить наш Blog.

func curry<A,B,C,D,R>(f: (A,B,C,D) -> R) -> A -> B -> C -> D -> R { 
  return { a in { b in { c in { d in f(a,b,c,d) } } } }
}

// Имеет тип: (Int, String, Bool, NSURL) -> Blog
let blog = { Blog(id: $0, name: $1, needsPassword: $2, url: $3) }
// Имеет тип: Int -> (String -> (Bool -> (NSURL -> Blog)))
let makeBlog = curry(blog)
// Или: Int -> String -> Bool -> NSURL -> Blog
let makeBlog = curry(blog)

//Имеет тип: Int?
let id = int(blogData, "id")
// Имеет тип: (String -> Bool -> NSURL -> Blog)?
let step1 = apply(makeBlog,id)
// Имеет тип: String?
let name = string(blogData,"name")
// Имеет тип: (Bool -> NSURL -> Blog)?
let step2 = apply(step1,name)


Шестая попытка


Теперь, после упрощения функции apply и после каррирования, наш код имеет множество обращений к функции apply.

func parse(blogData: [String:AnyObject]) -> Blog? { 
  let id = int(blogData,"id") 
  let name = string(blogData,"name") 
  let needsPassword = bool(blogData,"needspassword") 
  let url = string(blogData,"url").map { NSURL(string:$0) } 
  let makeBlog = curry { Blog(id: $0, name: $1, needsPassword: $2, url: $3) } 
  return apply(apply(apply(apply(makeBlog, id), name), needsPassword), url) 
}

Мы можем определить еще один оператор, <*>. Это то же самое, что и функция apply.

infix operator <*> { associativity left precedence 150 }
func <*><A, B>(l: (A -> B)?, r: A?) -> B? { 
  if let l1 = l { 
    if let r1 = r { 
      return l1(r1)
    }
  }
  return nil
}


Седьмая попытка… уже близко к цели


Теперь наш код практически закончен. Мы заменили множество вызовов функции apply нашим оператором <*>.

// перед введением оператора
return apply(apply(apply(apply(makeBlog, id), name), needsPassword), url) 

// после
return makeBlog <*> id <*> name <*> needsPassword <*> url }



Восьмая ( и последняя! ) попытка.


Все промежуточные предложения и конструкции if lets убраны. Все типы корректны, но если мы случайны укажем другие типы, то компилятор будет «жаловаться». Финальная версия нашего кода выглядит так:

func parse(blogData: [String:AnyObject]) -> Blog? { 
  let makeBlog = curry { Blog(id: $0, name: $1, needsPassword: $2, url: $3) } 
  return makeBlog <*> int(blogData,"id")
                  <*> string(blogData,"name")
                  <*> bool(blogData,"needspassword")
                  <*> string(blogData,"url").map { 
                        NSURL(string:$0) 
                      }
}


ВОПРОСЫ и ОТВЕТЫ



Вопрос: Ваше каррирование — очень ограниченное, есть ли способ написать более обобщенное каррирование?
Chris: В некоторых языках программирования это присутствует по умолчанию. Насколько мне известно, вы не можете писать по умолчанию curry, но вы можете написать эти curry функции с определенным числом аргументов, и компилятор сам выберет одну из них для вас. Возможно в дальнейшем это будет добавлено в Swift.

Вопрос: Есть ли какая-то поддержка оптимизации хвостовой рекурсии (tail-call optimization — TCO)?
Chris: Я так не думаю. Когда я выполнял множество рекурсивных вызовов, у меня было много аварийных завершений ( crashes). Но команда разработчиков Swift знает эти проблемы, и в дальнейшем их разрешит.

Вопрос: В ваших слайдах множество пользовательских (custom) операторов. Можете сказать немного о том, как это воздействует на новых разработчиков в команде?
Chris: Ужасно. Но все зависит от контекста — если вы пришли из Objective-C, и только начинаете использовать Swift, я не рекомендовал бы делать это. Тоже я сказал бы, если вы работаете в большой компании. Если у вас есть люди с функциональными языками программирования, им эти операторы более знакомы.

Вопрос: Если вы приходите из мира OOP (объектно-ориентированного программирования), то цель — разумный код. Как вы бы организовали свой код в функциональном программировании?
Chris: На самом верхнем уровне, это практически тоже самое. На более низком уровне, мне кажется полезным использовать множество вспомогательных (helper) функций. Вы видите множество вспомогательных функций в моем коде, что делает работу с кодом более удобной. Это немного похоже на философию UNIX, в котором у вас есть более мелкие функции, которые можно комбинировать друг с другом. В начале, это немного смущает, потому что вам приходится перестраивать свои мысли.

Вопрос: В своей книге вы рассказываете об обработке коллекций и таких вещах как функции map и reduce?
Chris: Конечно. Когда вы только начинаете знакомиться с функциональным программированием, первое, что вы изучаете — это эти функции. Вы можете выполнять map всего, даже массивов и Optionals, и, конечно, вы должны это освоить.

Вопрос: Что вы можете сказать о Swift по сравнению с другими языками программирования, которые вы использовали?
Chris: Я был в пешеходной экскурсии по Польше, и сидел в горной хижине, когда обновивTwitter, узнал что проходит WWDC. Когда они представили Swift, я был на седьмом небе, и немедленно загрузил eBook. Теперь мы можем делать все эти реально крутые функциональные штучки, но если рассматривать Swift как язык функционального программирования, то реально множество возможностей еще отсутствует. Но что я действительно люблю в Swift — это то, что вы можете применять функциональное программирование, и в тоже время, иметь доступ ко всему Cocoa. Это очень трудно для большинства языков программирования, таких как Haskell, взаимодействовать с Cocoa, а это супер мощная комбинация.

Вопрос: Осуществляются ли какие-то усилия по созданию открытой для функциональных операторов и функций, подобно библиотеки Scala Z для Scala?
Chris: Да, она называется Swift Z.

Вопрос: Я заметил, что в вашей презентации нет декларирования переменных var, не могли бы вы это прокомментировать?
Chris: Для меня функциональное программирование — это в основном неизменяемость (immutability), то есть, когда вы создаете значения и не модифицируете их. Это облегчает разработку кода, потому что вы точно знаете, что ваши значения не изменятся. Другое преимущество этого проявляется в параллельных вычислениях, потому что с изменяемыми объектами там очень тяжело работать. Но есть и некоторые недостатки — в классических языках программирования тяжело написать «быструю сортировку» ( так называемую quicksort) в одну строку. Но если я использую var, то стараюсь изолировать ее внутри функции, и снаружи функция выглядит «неизменяемой» (immutable).

Вопрос: Можете вы объяснить ваши соображения, когда вы устанавливали приоритет (precedence) для пользовательского оператора <*>, а для других операторов установки приоритита не было?
Chris: Я смотрел на приоритет операторов в Haskell, и думал о том, как перенести это в Swift для улучшения его работы. Я также смотрел на приоритеты для нормальных Swift операторов и тоже имел это ввиду при установки приоритетов операторов.

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

Вопрос: Заметили ли вы некоторые изменения в потреблении «памяти» или других ориентиров при использовании функционального программирования?
Chris: Я думаю, что если вы используете «изменяемые» данные, использование «памяти» будет лучше. Со множеством констант, вы используете больше и «памяти», и CPU, но вы можете сильно выиграть другим способом. Ваш код может быть быстрее и вы можете оптимизировать выполнение другим способом. Например, при использовании map дважды для массива. Если это pure преобразования, то вы можете скомбинировать две map в одну map и затем итерировать по массиву однократно. Такое будет очень тяжело оптимально написать на языках программирования типа C. Ясность — это огромный выигрыш, и, кроме того, я никогда не испытывал проблем с производительностью.

Вопрос: Одно из больших преимуществ функционального программирования — это «отложенное исполнение» ( laziness), есть ли способ сделать это в Swift?
Chris: Да, там есть ключевое слова «lazy», которое может сделать некоторую вещь «отложенной» (lazy), но я не знаю точно всех деталей этого. Вы также можете писать генераторы (generators) и последовательности (sequences), хотя документации по этому мало. Я точно не знаю, как эти вещи работают в Swift.

P.S. Что касается каррирования, то в Swift хотя и вручную, но можно сделать каррированную функцию создания структуры Blog без написания функций curry, а разместив в обертке makeBlog каждый параметр в отдельной круглой скобке:

static func makeBlog(id: Int)(name: String)(needsPassword: Int)(url:String) -> Blog {
        return Blog(id: id, name: name, needsPassword: Bool(needsPassword), url: toURL(url))
    }


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

Послесловие переводчика.


Крис придерживается идеи, что функциональное программирование в Swift лучше осваивать небольшими кусками кода, выполняющего полезную работу, и поэтому в своем журнале objc.io публикует такие маленькие функциональные зарисовки.
Но если вам это кажется пресным и слишком простым, то есть функциональные наработки в Swift, от которых реально «сносит крышу», то есть своего рода «функциональный экстрим». Но это, возможно, предмет следующих статей.
Теги:
Хабы:
Всего голосов 22: ↑16 и ↓6+10
Комментарии7

Публикации

Истории

Работа

Swift разработчик
16 вакансий
iOS разработчик
16 вакансий

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

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань