Сложность простоты

в 6:58, , рубрики: Go, golang, haskell, длиннопост, задачки, Занимательные задачки, никто не читает теги, Программирование, Совершенный код

Сложность простоты - 1

Как я писал в предисловии предыдущей статьи, я нахожусь в поисках языка, в котором я мог бы писать поменьше, а безопасности иметь побольше. Моим основным языком программирования всегда был C#, поэтому я решил попробовать два языка, симметрично отличающиеся от него по шкале сложности, про которые до этого момента приходилось только слышать, а вот писать не довелось: Haskell и Go. Один язык стал известен высказыванием "Avoid success at all costs"*, другой же, по моему скромному мнению, является полной его противоположенностью. В итоге, хотелось понять, что же окажется лучше: умышленная простота или умышленная строгость?

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


И сразу небольшая ремарка. Дело в том, что выражение (*) часто используют иронически, но это лишь потому, что люди его неверно парсят. Они читают это как "Avoid (success) (at all costs)", то есть "что бы ни произошло, если это ведет к успеху, мы должны это избежать", тогда как по-настоящему фраза читается как "Avoid (success at all costs)", то есть "если цена успеха слишком велика, то мы должны отступить на шаг и всё переосмыслить". Надеюсь, после этого объяснения она перестала быть смешной и обрела настоящий смысл: идеология языка требует правильно планировать свое приложение, и не вставлять adhoc костылей там, где они обойдутся слишком дорого. Идеология го, в свою очередь, скорее "код должен быть достаточно простым, чтобы в случае изменения требований его легко было выкинуть и написать новый".

Методика сравнения

Не мудрувствуя лукаво, я взял задачку, которую придумал товарищ 0xd34df00d и звучит она следующим образом:

Допустим у нас есть дерево идентификаторов каких-либо сущностей, например, комментариев (в памяти, в любом виде). Выглядит оно так:

|- 1
   |- 2
   |- 3
      |- 4
      |- 5

Ещё у нас есть некое API которое по запросу /api/{id} возвращает JSON-представление этого комментария.

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

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

В итоге вывод программы должен выглядеть примерно так:

|- 1
   |- 2
   |- 3
      |- 4
      |- 5

|- 1:Оригинальный комментарий
   |- 2:Ответ на комментарий 1
   |- 3:Ответ на комментарий 2
      |- 4:Ответ на ответ 1
      |- 5:Ответ на ответ 2

В качестве тестового апи я использовал любезно предоставленный первой строчкой гугла сервис https://jsonplaceholder.typicode.com/todos/

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

Отступление про Haskell

Если вы знаете, зачем нужны точка-оператор, доллар-оператор и как работает do-нотация, то смело пропускайте раздел и преходите к следующему. Иначе очень рекомендую почитать, будет интересно. А ещё будут монады на C#

Сложность простоты - 2

Здесь что-то на эльфийском. Не могу прочитать

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

Прежде чем начать статью, я хотел бы немного поговорить о структуре ML языков. Дело в том, что всем известно, что Lingua Franca низко- и среднеуровневых* языков это С. Если ты пишешь на джаве, а твой коллега на питоне, просто пошли ему сниппет на С, он поймет. Работает и в обратную сторону. Все знают си, и на чем бы они ни писали по работе, на нем они всегда договорятся.

* под низкоуровневыми языками я имею ввиду языки С/С++/..., а под среднеуровневыми — C++/C#/Java/Kotlin/Swift/...

Но менее известно, что в высокоуровневых языках это Haskell. В Scala/F#/Idris/Elm/Elexir/… тусовках если не знаешь, на чем пишет твой визави — пиши на хаскелле, не ошибешься. Однако программистов на этих языках не так много, и для более широкого охвата статьей я приведу небольшой разговорник, чтобы вариант на Haskell не казался китайской грамотой. Я буду приводить примеры на Rust/C#, они должны быть понятны любому человеку, знакомому с С. Термины Option/Maybe, Result/Either и Task/Promise/Future/IO означают одно и то же в разных языках и могут быть взаимозаменяемо использованы друг вместо друга.

Итак, Если вы видете перед собой

data Maybe a = Just a | Nothing  -- это комментарий

То это означает

// это тоже комментарий
enum Maybe<T> {
   Just(T),
   Nothing
}

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

data Either a b = Left a | Right b

мы теперь легко можем прочитать, и переписать знакомым нам образом

enum Either<A,B> {
   Left(A),
   Right(B)
}

Довольно логично и последовательно. Стоит немного привыкнуть, и эта запись будет вам казаться вполне естественной (лично я переучился где-то за полчаса написания кода).

Ну и конечно кроме тип-сумм есть и типы-произведения, это обычные структуры, которые пишутся так:

data Comment = Comment {
      title :: String
    , id  :: Int
    } deriving (Show) -- просим компилятор автоматически генерировать функцию
              -- преобразования в строку (аналог метода ToString() в C#/Java)

и переводятся как:

#[derive(Display)]
struct Comment {
    title: String,
    id: i32
}

Пока вроде все просто, идем дальше.

Если же вы видете перед собой

sqr :: Int -> Int
sqr x = x*x

main :: IO () -- IO это специальный тип, обозначающий взаимодействие с внешним миром, в частности вывод на консоль
main = print (sqr 3)

То это

fn sqr(x: i32) -> i32 { x*x }
fn main() {
   println!("{}", sqr(3));
}

Здесь мы объявляем две функции, одна — функция возведения в квадрат, а другая — вездесущий main.

Одна особенность, которую мы сразу видим: в С-языках вызов функции обособляется собками, в ML-подобных — пробелом. Но скобками все-равно приходится пользоваться из-за левой ассоциативности языка. Поэтому мы выделяем (sqr 3) в скобочки, чтобы сперва вычислилось это значение, а затем оно использовалось для вывода на экран. Без скобочек компилятор попробует сначала выполнить print sqr и конечно же выдаст ошибку компиляции, потому sqr является типом Fn(i32) -> i32 (Func<int, int> в терминах C#), для которого не определен метод show (местный ToString()).

Другая особенность: объявление функции в хаскеле состоит из двух частей: первая (необязательная) — описание сигнатуры, и вторая — непосредственно тело функции. Из-за особенностей языка (в которые я сейчас не буду углубляться) все аргументы перечисляются стрелочкой ->, последнее значение справа это результат функции. Например, если вы видите функцию foo :: Int -> Double -> String -> Bool, то эта функция которая называется foo и принимающая три аргумента: один целочисленный, один с плавающей запятой и один строковый, и возвращащий булевское значение.

Теперь попробуйте проверить себя, что за сигнатура у функции bar :: (Int -> Double) -> Int -> (String -> Bool)?

Ответ

Функция по имени bar принимает два аргумента: функцию Int -> Double и значение типа Int, и возвращает функцию String -> bool.

Rust-сигнатура: fn bar(f: impl Fn(i32) -> f64, v: i32) -> impl Fn(String) -> bool

C#-сигнатура: Func<string, bool> Bar(Func<int, double> f, int v)

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

Теперь же, если вы видите

sqr x = x*x     -- обратите внимание на опущенные сигнатуры, они будут выведены
add x y = x + y -- Однако: FOR EXAMPLE PURPOSES ONLY!
add5_long x = add 5 x
add5 = add 5    -- как и в математике, иксы по обе части уравнения можно сократить, 
                -- поэтому add5 это сокращенная запись варианта add5_long. 
                -- Принцип схож с Method Groups в C#
                -- Официальное название такого приема - каррирование

main :: IO ()
main = putStrLn (show (add 10 (add5 (sqr 3)))) 

то это переводится как

fn sqr(x: i32) -> i32 { x*x }
fn add(x: i32, y: i32) -> i32 { x + y }
fn add5(x: i32) -> i32 { add(5, x) }

fn main() {
   println!("{}", ToString::to_string(add(10, add(5, sqr(3)))));                
}

Естественно, писать столько скобочек утомительно. Поэтому хаскеллисты придумали использовать символ $ для того чтобы им их заменять. Таким образом a $ b всего лишь означает a (b). Поэтому пример выше можно переписать так:

main = putStrLn $ show $ add 10 $ add5 $ sqr 3 -- ура! нет скобочек

С таким количество долларов в программах хаскеллистам была бы открыта дорога во все банки мира, но им это почему-то не понравилось. Поэтому они придумали писать точки. Оператор точка — это оператор композиции, и он определяется как f (g x) = (f . g) x. Например print (sqr 3) можно записать как (print . sqr) 3. Из функций "распечатай" и "возведи в квадрат" мы построили функцию "распечатай возведенный в квадрат аргумент", а потом передали ей значение 3. С его помощью пример выше будет выглядеть:

main = putStrLn . show . add 10 . add5 $ sqr 3

Стало намного чище, но заканчиваются ли на этом плюсы этого оператора? Как вы и догадались, ответ — нет, теперь благодаря этому мы можем вынести это все в отдельную функцию, придумать ей легкопроизносимое и очевидное имя и переиспользовать где-нибудь ещё:

-- функция прибавляет к аргументу 5, затем прибавляет 10, затем преобразует в строчку, затем выводит на экран
putStrLnShowAdd10Add5 = putStrLn . show . add 10 . add5
-- аналогичная запись putStrLnShowAdd10Add5 x = putStrLn . show . add 10 . add5 x
-- поэтому вычисление происходит справа налево (как, впрочем, и во всех языках)

main :: IO ()
main = putStrLnShowAdd10Add5 $ sqr 3

Наша программа выведет ожидаемое "24". Красота и лаконичность подобного подхода обуславливает популярность оператора точки в хаскельном коде — с оператором доллар так бы не получилось, потому что он просто позволят экономить скобочки, а точка — строить новые функции на базе других функций — любимое занятие ФП разработчиков.

Мы узнали про ML синтаксис практически всё, чтобы читать произвольный Haskell код, остался последний рывок и с разговорником покончено

Последний рывок

main :: IO ()
main = 
  let maybe15 = do
      let just5 = Just 5    -- создаем объект типа Maybe с конструктором Just (см. первый пример) из начением 5
      let just10 = Just 10  -- то же самое с 10
      a <- just5            -- Пытаемся достать из него значение, если оно есть, то сохранить его в `a`.  если тут не будет значения то следующая строчка не выполнится!
      b <- just10           -- то же самое с `b`
      return $ a + b        -- если получилось, складываем числа. В противном случае вернется одна из веток выше провалила проверку на наличие значения и вернула Nothing
  in 
    print maybe15

Такая запись, область с выделением do-блока и использованием <- стрелочек, называется do-нотация, и она работает с любыми типами, являющимися монадой (не пугайтесь, это не страшно). На примере его использования с типом Maybe вы могли сразу узнать элвис-оператор (он же "Null condition operator"), позволяющий обрабатывать null-значения по цепочке, который возвращает null, если он не смог где-то получить значение. do-синтксис весьма-похож на него, но намного шире по возможностям.

Подумайте, где вы могли такое видеть? Оператор, который позволяет вам "раскрыть" значение, лежащее в некоемом контейнере (в данном случае Maybe, но может быть и любой другой, например Result<T,Error>, или как его называют в ФП языках — Either), а если не получилось, то прервать выполнение? Предлагаю вам немного подумать, стрелочка <- может вам казаться странной, но на самом деле вы это наверняка писали тысячу раз в своем любимом языке.

Ответ

А ведь это ни что иное, как общий случай async/await (я использую синтаксис C# т.к. в Rust async-await ещё не стабилизирован):

async ValueTask<int> Maybe15() {
    var just5 = Task.FromResult(5);
    var just10 = Task.FromResult(10);
    int a = await just5; // если тут будет ошибка то следующая строчка не выполнится!
    int b = await just10;
    return a + b;
}

Console.WriteLine(Maybe15().ToString()) // выведет ожидаемое 15

Здесь я использую тип Task вместо Maybe, но даже по коду видно, как они похожи.
В целом, можно воспринимать do-нотацию как расширение async/await (который работает только с асинхронными контейнерами навроде Task) на тип любых контейнеров, где do — это "начало async-блока", а <- — это "авейт" (раскрытие содержимого контейнера). Частные случаи включают в себя Future/Option/Result/ List, и многие другие.

Магия C#

На самом деле в C# есть полноценная do-нотация, а не только ограниченный async/await для Task. И имя ему, барабанная дробь, LINQ-синтаксис. Да, многие давно про него забыли, кто-то наоборот знает про этот маленький трюк, но для полноты картины рассказать о нем точно не помешает. Если мы напишем пару хитрых методов расширения для Nullable, то переписать код с Haskell в таком случае можно буквально один в один (не прибегая к аналогии с Task). Вспомним, как оно выглядело (немного упрощу):

main :: IO ()
main = 
  let maybe15 = do
      a <- Just 5
      b <- Just 10
      return $ a + b 
  in 
    print maybe15

И теперь версия C#

int? maybe15 = from a in new int?(5)
               from b in new int?(10)
               select a + b;

Console.WriteLine(maybe15?.ToString() ?? "Nothing");

Вы видите разницу? Я — нет, за исключением того что haskell умеет выводить имя конструктора Nothing, а в C# это приходится делать самостоятельно.

Поиграться и посмотреть как же оно работает можно в заботливо подготовленном repl (для просмотра результатов программы прокрутите нижний див до конца). По ссылочке приложены также примеры работы с Result и Task. Как видите, работа с ними абсолютно идентична. Все контейнеры, с которыми можно работать подобным образом в ФП называются монадами (оказывается, это понятие не так уж страшно, правда?).

Ну а тут можно посмотреть как то же самое выглядит на Haskell: https://repl.it/@Pzixel/ChiefLumpyDebugging

Итак, вступление уже изрядно затянулось, предлагаю перейти непосредственно к коду

Haskell

Сложность простоты - 3

С чем сталкивается каждый начинающий хаскеллист сразу после установки языка? Правильно, IDE ничего не подсказывает

Примерно весь первый час после того как я решил начать с версии на хаскелле я настраивал окружение: устанавливал GHC (компилятор), Stack (сборщик и депенденси менеджер, похож на cargo или dotnet), IntelliJ-Haskell, и ждал установки всех зависимостей. Потом пришлось повозиться с идеей, но после очистки кешей и пары профилактических перезагрузок IDE все наладилось.

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

main :: IO ()
main = putStrLn "Hello, World!"

Ура, оно живое! Теперь начинаем с первого пункта, вывести дерево на экран. После непродолжительного гуглежа находим стандартный тип Data.Tree с прекрасным методом drawTree. Отлично, пишем, прям как написано в документации:

import Data.Tree

main :: IO ()
main = do
    let tree = Node 1 [Node 2 [], Node 3 [Node 4 [], Node 5 []]]
    putStrLn . drawTree $ tree -- в этот момент я пошел гуглить, что такое точка и доллар.
                               -- результат моего расследования вы прочитали в предыдущей части

И получаем нашу первую ошибку:

    • No instance for (Num String) arising from the literal ‘1’
    • In the first argument of ‘Node’, namely ‘1’
      In the expression:
        Node 1 [Node 2 [], Node 3 [Node 4 [], Node 5 []]]

Где-то секунд 30 я разглядывал её, потом подумал "при чем тут стринга?.. Хм… А, наверное он может вывести только дерево строк", гуглю "haskell map convert to string", и по первой ссылке нахожу решение использовать map show. Что ж проверяем: меняем последнюю строчку на putStrLn . drawTree . fmap show $ tree, компилируем, и радуемся нарисованному дереву

Отлично, дерево мы рисовать научились, а как преобразовать его в дерево комменатриев?
Гуглим, как объявить структуры, и пишем метод загрузки комментария по номеру. Раз я пока не знаю, как писать сетевое взаимодействие, мы напишем метод-заглушку который возвращает какой-то константный комментарий. Т.к. я уже имел какой-то опыт Rust я знал, что в современных языках все асинхронные операции по АПИ похожи на Option — опциональный тип, поэтому решил сделать сделать возвращаемое значение метода-заглушки Maybe (местный Option), а потом, когда разберусь как делать HTTP запросы, заменю на нормальный асинк. А пока пусть возвращает вместо комментария число, преобразованное в строку.

Дописываем объявление структуры и метод-заглушку:

import Data.Tree

data Comment = Comment {
      title :: String
    , id  :: Int
    } deriving (Show)

getCommentById :: Int -> Maybe Comment
getCommentById i = Just $ Comment (show i) i

main :: IO ()
main = do
    let tree = Node 1 [Node 2 [], Node 3 [Node 4 [], Node 5 []]]
    putStrLn . drawTree . fmap show $ tree

Все отлично, теперь нужно применить нашу функию-заглушку для каждого узла. На этом моменте я загуглил "haskell map maybe list" (потому что на практике знаю, что мап списка он ничем не отличается от мапа дерева, а загуглить будет проще), и второй ссылкой нашел ответ "Просто используйте mapM". Пробуем:

import Data.Tree
import Data.Maybe

data Comment = Comment {
      title :: String
    , id  :: Int
    } deriving (Show)

getCommentById :: Int -> Maybe Comment
getCommentById i = Just $ Comment (show i) i

main :: IO ()
main = do
    let tree = Node 1 [Node 2 [], Node 3 [Node 4 [], Node 5 []]]
    putStrLn . drawTree . fmap show $ tree
    let commentsTree = mapM getCommentById tree
    putStrLn . drawTree . fmap show $ fromJust commentsTree

Получаем:

1
|
+- 2
|
`- 3
   |
   +- 4
   |
   `- 5
Comment {title = "1", id = 1}
|
+- Comment {title = "2", id = 2}
|
`- Comment {title = "3", id = 3}
   |
   +- Comment {title = "4", id = 4}
   |
   `- Comment {title = "5", id = 5}

Фух, вроде даже работает. Пришлось дополнительно добавить fromJust (аналогичен unwrap() в расте или Nullable.Value в C#, пытается развернуть значение, если там пусто, то бросает исключение), в остальном сделали все так, как написано по ссылке и получили вывод нашего дерева на экран.

После этого я немного застопорился, потому что я не понял, как делать асинхронные запросы и парсить JSON'ы.
К счастью, в чатике мне быстренько помогли и дали ссылки на wreq и местную либу для десериализации. Минут 15 я игрался с примерами после чего получил предварительно рабочий код:

{-# LANGUAGE DeriveGeneric #-}

import Data.Tree
import Data.Maybe
import Network.Wreq
import GHC.Generics
import Data.Aeson
import Control.Lens

data Comment = Comment {
      title :: String
    , id  :: Int
    } deriving (Generic, Show)

instance FromJSON Comment

getCommentById :: Int -> IO Comment
getCommentById i = do
  response <- get $ "https://jsonplaceholder.typicode.com/todos/" ++ show i
  let comment = decode (response ^. responseBody) :: Maybe Comment
  return $ fromJust comment

main :: IO ()
main = do
    let tree = Node 1 [Node 2 [], Node 3 [Node 4 [], Node 5 []]]
    Prelude.putStrLn . drawTree . fmap show $ tree
    let commentsTree = mapM getCommentById tree
    Prelude.putStrLn . drawTree . fmap show $ fromJust commentsTree

И… Сначала ждем 20 минут, пока скачаются и соберутся все зависимости (привет, сборка reqwest в Rust), а затем получаем нашу вторую ошибку:

    * Couldn't match expected type `Maybe (Tree a0)'
                  with actual type `IO (Tree Comment)'
    * In the first argument of `fromJust', namely `commentsTree'
      In the second argument of `($)', namely `fromJust commentsTree'
      In a stmt of a 'do' block:
        putStrLn . drawTree . fmap show $ fromJust commentsTree
   |
28 |     Prelude.putStrLn . drawTree . fmap show $ fromJust commentsTree
   |                                                        ^^^^^^^^^^^^^

Ну да, мы же использовали fromJust чтобы сделать преобразование Maybe Tree -> Tree, а теперь же у нас вместо заглушки настоящее IO происходит, которое и возвращает соответственно IO Tree вместо Maybe Tree. Как же достать значение? Как и прежде, обращаемся в гугл за этой информацией и получаем "используйте оператор <-" первой ссылкой. Пробуем:

main :: IO ()
main = do
    let tree = Node 1 [Node 2 [], Node 3 [Node 4 [], Node 5 []]]
    Prelude.putStrLn . drawTree . fmap show $ tree
    commentsTree <- mapM getCommentById tree
    Prelude.putStrLn . drawTree . fmap show $ commentsTree

Ура, работает. Только медленно. Ах да, мы же забыли запараллелить.

Следующие минут 20 я гуглил, как распараллелить обход дерева. Находил всякие странные Concurrent-пакеты, какие-то стратегии обхода, ещё что-то. Но ищущий да обрящет, и в конце концов я наткнулся на async. В итоге параллельная версия потребовала некоторых монументальных изменений, но в конце концов все-таки заработала:

commentsTree <- mapConcurrently getCommentById tree

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

Примечание: последние несколько ссылкок в песочнице не собираются т.к. они требуют библиотек, создающих HTTP соединений, а repl.it их не разрешает. Желающие могут скачать и скомпилировать пример локально

На этом мой эксперимент с написанием на хаскелле завершается. Путем нехитрого гугла и интуиции от опыта работы с C# и Rust получилось меньше чем за час написать рабочую программу. Из них почти половину времени заняла просто установка 67 зависимостей веб-клиента. В принципе я был готов к этому, у reqwest в расте больше 100 зависимостей если мне не изменяет память, но все равно немного неприятно. Хорошо, что при последующей разработке все эти пакеты уже закэшированны локально и это был разовый оверхед на разворачивание окружения.

Простота параллелизации меня очень приятно удивила. А также я внезапно обнаружил, что я совершенно не использую тот факт, что у меня дерево. Ради эксперимента я решил поменять дерево на массив, и вот какие изменения мне пришлось внести:

main = do
    let tree = [1,2,3,4,5]
    print tree
    commentsTree <- mapConcurrently getCommentById tree
    print commentsTree

выводит

[1,2,3,4,5]
[Comment {title = "delectus aut autem", id = 1},Comment {title = "quis ut nam facilis et officia qui", id = 2},Comment {title = "fugiat veniam minus", id = 3},Comment {title = "et porro tempora", id = 4},Comment {title = "laboriosam mollitia et enim quasi adipisci quia provident illum", id = 5}]

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

Ну, хаскель оставил приятные впечатления, давайте перейдем к следующей части, go. Его синтаксис больше похож на привычные мне языки, поэтому мне не придется тратить время на параллельное изучение синтаксиса (как видите, для go не понадобилось делать словарика) и я смогу сразу написать код. Я морально смирился, что мне придется писать более топорно (например, придется реализовать два разных типа для деревьев идентификаторов и деревьев комментариев), зато я смогу воспользоваться всей мощью главной рекламной фичи го — горутинами!

Go

Сложность простоты - 4

Сейчас го покажет, как нужно писать асинхронные программмы

Так как мы уже немного набили руку с предыдущим вариантом и знаем, что хотим получить в итоге, то просто открываем https://play.golang.org/ и пишем код.

Сначала гуглим, как в го создаются структуры. Затем, как их инициализировать. Через минуту первая программа на go готова:

package main

type intTree struct {
    id int
    children []intTree
}

func main() {
    tree := intTree{
        id: 1,
        children: []intTree {
            {
                id: 2,
                children: []intTree{

                },
            },
            {
                id: 3,
                children: []intTree{
                    {
                        id: 4,
                    },
                    {
                        id: 5,
                    },
                },
            },
        },
    }
}

Пытаемся скомпилировать — ошибка, tree declared and not used. Окей, в принципе я заранее знал, что го не разрешает иметь неиспользуемые переменные, переименовываем tree в _. Пробуем собрать, получаем ошибку no new variables on left side of :=. Ну, видимо нам даже проверить что мы не ошиблись в коде создания дерева не дадут, придется сразу дописывать форматирование и вывод на экран. Тратим ещё пару минут на то, чтобы узнать, как выводить форматирующую строкун а экран и как сделать foreach цикл и дописываем необходимые функции:

func showIntTree(tree intTree) {
    showIntTreeInternal(tree, "")
}

func showIntTreeInternal(tree intTree, indent string) {
    fmt.Printf("%v%vn", indent, tree.id)
    for _, child := range tree.children {
        showIntTreeInternal(child, indent + "  ")
    }
}

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

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

type comment struct {
    id int
    title string
}

type commentTree struct {
    value comment
    children []commentTree
}

func loadComments(node intTree) commentTree {
    result := commentTree{}
    for _, c := range node.children {
        result.children = append(result.children, loadComments(c))
    }
    result.value = getCommentById(node.id)
    return result
}

func getCommentById(id int) comment { 
    return comment{id:id, title:"Hello"} // наша заглушка в случае go
}

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

func showCommentsTree(tree commentTree) {
    showCommentsTreeInternal(tree, "")
}

func showCommentsTreeInternal(tree commentTree, indent string) {
    fmt.Printf("%v%v - %vn", indent, tree.value.id, tree.value.title)
    for _, child := range tree.children {
        showCommentsTreeInternal(child, indent + "  ")
    }
}

С первой задачей мы почти справились, осталось только научиться получать реальные данные от веб-сервиса, и заменить заглушку на получение данных. Гуглим, как делать http запросы, гуглим, как десериализовывать JSON, и спустя ещё 5 минут дописываем следующее:

func getCommentById(i int) comment {
    result := &comment{}
    err := getJson("https://jsonplaceholder.typicode.com/todos/"+strconv.Itoa(i), result)
    if err != nil {
        panic(err) // для игрушечной задачи сойдет
    }
    return *result
}

func getJson(url string, target interface{}) error {
    var myClient = &http.Client{Timeout: 10 * time.Second}
    r, err := myClient.Get(url)
    if err != nil {
        return err
    }
    defer r.Body.Close()

    return json.NewDecoder(r.Body).Decode(target)
}

Запускаем, получаем

1
  2
  3
    4
    5
0 - 
  0 - 
  0 - 
    0 - 
    0 - 

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

Минут через 5 дебага и посматривания в документацию, стало понятно, что проблема в регистрозависимости десериализатора: распарсить {Title = "delectus aut autem", Id = 1} как cтруктуру id, title го не может. Заодно находим правила именования, что с маленькой буквы пишутся приватные члены, а с большой — публичные. В принципе, решений потенциальных два: первое — просто сделать поля публичными с большой буквы, второе — повесить специальные атрибуты чтобы указать имена, из которых нужно парсить.

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

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

Тратим ещё минут 5, узнаем про вейтгруппы и go-нотацию. Пишем

func loadComments(root intTree) commentTree {
    var wg sync.WaitGroup
    result := loadCommentsInner(&wg, root)
    wg.Wait()
    return result
}

func loadCommentsInner(wg *sync.WaitGroup, node intTree) commentTree {
    result := commentTree{}
    wg.Add(1)
    for _, c := range node.children {
        result.children = append(result.children, loadCommentsInner(wg, c))
    }
    go func() {
        result.value = getCommentById(node.id)
        wg.Done()
    }()
    return result
}

И снова получаем

0 - 
  0 - 
  0 - 
    0 - 
    0 - 

Эмм, ну а теперь-то почему? Начинаем разбираться, ставим брейкпоинт на начало функции, проходим её. Становится понятно, что мы выходим из функции не дожидаясь результата, поэтому когда wg.Wait() на верхнем уровне дожидается сигнала от всех горутин, у него уже на руках есть сформированное пустое дерево, которое он и возвращает.

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

func loadComments(root intTree) commentTree {
    ch := make(chan commentTree, 1)   // создаем канал
    var wg sync.WaitGroup             // создаем вейт группу
    wg.Add(1)                         // у неё будет один потомок
    loadCommentsInner(&wg, ch, root)  // грузим дерево
    wg.Wait()                         // дожидаемся результата
    result := <- ch                   // получаем значение из канала
    return result
}

func loadCommentsInner(wg *sync.WaitGroup, channel chan commentTree, node intTree) {
    ch := make(chan commentTree, len(node.children))  // создаем канал по количеству детей
    var childWg sync.WaitGroup                        // создаем вейт группу для детей
    childWg.Add(len(node.children))
    for _, c := range node.children {
        go loadCommentsInner(&childWg, ch, c)         // рекурсивно грузим детей в горутинах (параллельно)
    }
    result := commentTree{
        value: getCommentById(node.id),               // синхронно грузим себя
    }
    if len(node.children) > 0 {                       // если у нас есть дети, которых надо дождаться, то ждем их
        childWg.Wait()
        for value := range ch {                       // все дети сигнализировали об окончании работы, забираем у них результаты
            result.children = append(result.children, value)
        }
    }
    channel <- result                                 // отдаем результат в канал наверх
    wg.Done()                                         // сигнализируем родителю об окончании работы
}

Запускаем и… тишина. Ничего не происходит. Честно говоря, в этот момент я ощутил некоторое замешательство. Я слышал, что в го есть детектор дедлоков, раз он молчит, значит мы не залочились. То есть какая-то работа выполняется. Но какая?

Ещё минут 15 я расставлял ифчики, перестраивал код, добавлял/удалял вейтгруппы, тасовал каналы… Пока наконец не догадался заменить получение по HTTP на нашу изначальную заглушку:

func getCommentById(id int) comment {
    return comment{Id: id, Title: "Hello"}
}

После чего го выдал:

1
  2
  3
    4
    5
fatal error: all Goroutines are asleep - deadlock!

goroutine 1 [semacquire]:
sync.runtime_Semacquire(0xc00006e228)
    C:/go/src/runtime/sema.go:56 +0x49
sync.(*WaitGroup).Wait(0xc00006e220)
    C:/go/src/sync/waitgroup.go:130 +0x6b
main.loadCommentsInner(0xc00006e210, 0xc0000ce120, 0x1, 0xc000095f10, 0x2, 0x2)
    C:/Users/pzixe/go/src/hello/hello.go:47 +0x187
main.loadComments(0x1, 0xc000095f10, 0x2, 0x2, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0)
    C:/Users/pzixe/go/src/hello/hello.go:30 +0xec
main.main()
    C:/Users/pzixe/go/src/hello/hello.go:94 +0x14d

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

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

  1. Строить ноды рекурсивно в узлах это плохо. Нужно создать всё дерево заранее, а потом дать ссылки на ноды каждой горутине, чтобы она в это общее для всех горутин место по нужному адресу перезаписало пустую структуру comment на полученную из JSON
  2. Вызывать горутины рекурсивно тоже плохо. По опыту сишарпа я привык, что стартовать Task внутри други Task и аггрегация через WhenAny/WhenAll это совершенно нормальная операция. В го, судя по той информации, что мне сказали, это не так. Как я понял, там и планировщику плохо становится, и с производительностью наступает кирдык. То есть правильный сценарий использования — исключительно в роли веб-сервера а-ля
    for httpRequest := range webserer.listen() {
        go handle(httpRequest)
    } 
  3. На практике никто не пишет по функции на каждый чих и go-way будет написать одну функцию printTree:

    func printTree(tree interface{}) string {
        b, err := json.MarshalIndent(tree, "", "  ")
        if err != nil {
            panic(err)
        }
        return string(b)
    }

    Где interface {} — это аналог шарпового dynamic или тайпскриптового any, то есть локальное отключение всех проверок типов. Дальше с таким объектом надо работать либо через рефлексию, либо через даункаст к известному типу.

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

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

func loadComments(root intTree) commentTree {
    result := commentTree{}
    var wg sync.WaitGroup
    loadCommentsInner(&result, root, &wg)
    wg.Wait()
    return result
}

func loadCommentsInner(resNode *commentTree, node intTree, wg *sync.WaitGroup) {
    wg.Add(1)
    for _, res := range node.children {
        resNode.children = append(resNode.children, &commentTree{})
        loadCommentsInner(resNode.children[len(resNode.children)-1], res, wg)
    }
    resNode.value = getCommentById(node.id)
    wg.Done()
}

Что тут происходит? Ну, тут учтено первое замечание из списка рекомендаций "go-way": мы изначально создаем пустое дерево, а потом начинаем заполнять его из разных горутин. У нас нет кучи вейтгруп на каждый узел дерева, есть одна единственная, куда каждая нода себя добавляет, и которую мы наверху ждем.

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

Ответ

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

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

func loadComments(root intTree) commentTree {
    result := commentTree{}
    var wg sync.WaitGroup
    loadCommentsInner(&result, root, &wg)
    wg.Wait()
    return result
}

func loadCommentsInner(resNode *commentTree, node intTree, wg *sync.WaitGroup) {
    wg.Add(len(node.children))
    for _, res := range node.children {
        child := &commentTree{}
        resNode.children = append(resNode.children, child)
        res := res
        go func() {
            defer wg.Done()
            loadCommentsInner(child, res, wg)
        }()
    }
    resNode.value = getCommentById(node.id)
}

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

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

1
  2
  3
    4
    5
START 1
START 3
START 5
DONE 5
START 2
DONE 2
START 4
DONE 4

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

Ну что ж, фикс в любом случае очень прост, ведь мы знаем, сколько сообщений придет в канал:

if len(node.children) > 0 {
    childWg.Wait()
    for i := 0; i < len(node.children); i++ { // выходим когда получили ожидаемое количество сообщений
        result.children = append(result.children, <-ch)
    }
}
channel <- result
wg.Done()

Что ж, пришло время подводить итоги? А вот и нет, мне в одном из чатов человек, хорошо шарящий в го сказал, что это всё ещё неидеоматичный код, и спустя полчасика скинул мне другой вариант. Полностью он доступен по ссылке, а тут я выделю интересные детали:

  1. func (n *IdTree) LoadComments(ctx context.Context) (*CommentTree, error) — вместо свободной функции используется функция на объекте tree
  2. g, ctx := errgroup.WithContext(ctx) — никаких ручных вейтгруп
  3. i, c := i, c // correct capturing by closure — судя по всему, замыкания работают по ссылке, а не значению. Нужно помнить об этом при написании лямбд в цикле
  4. g.Go(func() error — Никаких ручных горутин, всем рулит некий контекст

Давайте посмотрим, что у нас вышло, и перейдем к выводам.

Например, насколько легко было изменить код с синхронного на асинхронный? Ну, достаточно ощутимо. Понадобились вейтгруппы (в наивном варианте), каналы, мы легко сначала получили рейс кондишн (из-за неверно понятых гарантий), а потом всё нафиг задедлочили. Наверное, если держать постоянно это в голове то это несложно, но новичку обязательно стрельнет.

Теперь давайте оценим, насколько легко изменить дерево на массив? Ну, тут код наверное проще будет выкинуть и заново переписать, потому что все функции знают, что работают с деревом. Оставить можно без изменений только getCommentById.

С этими мыслями давайте двигаться дальше.

C♯

C# мой основной инструмент, которым я пользуюсь последние 6 лет, и конечно же я не мог обойти его стороной. У меня не будет таких подробных шагов как в прошлых пунктах, потому что я знал, что пишу, и минут за 8 с перерывами на общение в телеграме набросал такое решение:

class Program
{
    class Comment
    {
        public int Id { get; set; }
        public string Title { get; set; }

        public override string ToString() => $"{Id} - {Title}";
    }

    private static readonly HttpClient HttpClient = new HttpClient();

    private static Task<Comment> GetCommentById(int id) =>
        HttpClient.GetStringAsync($"https://jsonplaceholder.typicode.com/todos/{id}")
            .ContinueWith(n => JsonConvert.DeserializeObject<Comment>(n.Result));

    private static async Task<Tree<Comment>> GetCommentsTree(Tree<int> tree)
    {
        var children = Task.WhenAll(tree.Children.Select(GetCommentsTree));
        var value = await GetCommentById(tree.Value);
        var childrenResults = await children;
        return new Tree<Comment> { Value = value, Children = childrenResults };
    }

    private static async Task Main()
    {
        var tree = Tr(1, new[] { Tr(2), Tr(3, new[] { Tr(4), Tr(5) }) });
        PrintTree(tree);
        var comment_tree = await GetCommentsTree(tree);
        PrintTree(comment_tree);
    }

    class Tree<T>
    {
        public T Value { get; set; }
        public Tree<T>[] Children { get; set; }
    }

    private static void PrintTree<T>(Tree<T> tree, string intendantion = "")
    {
        Console.WriteLine(intendantion + tree.Value);
        foreach (var child in tree.Children)
        {
            PrintTree(child, intendantion + "  ");
        }
    }

    static Tree<T> Tr<T>(T value, Tree<T>[] children = null) => new Tree<T> { Value = value, Children = children ?? Array.Empty<Tree<T>>() };
}

Тут сразу я написал параллельную версию, как будет выглядеть синхронная? Ну, точно так же, только вместо Task.WhenAll нужно будет написать foreach и ждать каждого ребенка отдельно. То есть в плане параллелизации гибкость высокая.
Что с заменой дерева на список? Ну, тут как и в случае с го придется выкидывать весь код и писать заново. Оставить можно тоже как и в его случае только GetCommentById. От этого можно абстрагироваться, реализовавAsyncEnumerable перечисление, тогда мы отвяжемся от конкретного представления нашей структуры данных, но это уже получится неоправданное для такой простой задачи усложнение.

Выводы

Сложность простоты - 5

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

Haskell go C#
Количество строк общего кода* 17 76 28
Общего времени на разработку -
Чистого времени на кодирование однопоточного варианта 30м 10м -
Чистого времени на кодирование многопоточного варианта 15м 50м -
Простота замены структуры данных
Простота замены однопоточного варианта на многопоточный
Простота условного выполнения операций отображения** ✓✗
Время чистой сборки ~30м
Время инкрементальной компиляции 5c 1.9c*** 1.6c

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

** Допустим, мы хотим выкидывать всех детей у которых сумма айдишек равна 42. В сишарпе, полагаю, это будет выглядеть примерно так:

async Task<Tree<Comment>> GetCommentsTree(Tree<int> tree)
{
    if (tree.Children.Sum(c => c.Value) == 42)
    {
        return new Tree<Comment> { Value = await GetCommentById(tree.Value), Children = Array.Empty<Tree<int>> };
    }
    ... дальше то же самое что и в прошлом варианте

В го это будет решаться схожим способом.

А вот в хаскелле скорее всего придется предварительно пофильтровать дерево, выкинув ненужные ноды, и только потом передавать в mapParallel. Звучит не особо сложно, но не так просто, как в случае go или C#.

*** С го произошла странная фигня, что первые два билда собирались 7 секунды, последующие два 1.9 секунд, а дальше упало до 200мс. Я честно не очень понял, какие значения сюда писать и взял те что были в середине.


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

Насколько идиоматичным получается "новичковый" код

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

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

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

Haskell: если нет никакого опыта других языков, кривая довольно крутая, иначе почти со всеми вещами можно найти общее. По моему рассказу можно видеть, как я то и дело говорю "это похоже на сишарп", "это я знаю из Rust", и так далее. То есть либо нужна интуиция и опыт разработчика, либо нужно читать специализированные книжки по ФП и гуглить чуть менее успешно. С другой стороны, компилятор как и в расте очень помогает. У программы есть два состояния: не компилирующаяся и правильно работающая. Возможно, этот закон ломается для больших и сложных программ, но в моем примере получилось именно так.

Go: кривая обучения начинается как довольно пологая. Первоначальный вариант мы там минут за 10 вроде написали. Но вот потом начинаются чудеса. Программа компилируется, но в рантайме просиходит совсем не то, что мы хотели. К кому обращаться с проблемой, непонятно, гугл выдает однотипные истории про то, как чудесно передавать в канал значения и как оттуда читать, а почему у нас все развалилось — неизвестно. Можно спросить в чатиках, но про них ниже. Мне не удалось найти каких-то библиотек, которые могли бы помочь с этой сложностью, насколько я понял, предлагается каждый раз решать их самостоятельно. Поправьте меня, пожалуйста, в комментариях, если я ошибаюсь. Таким образом после пологого старта я лично наблюдаю резкий рывок вверх, который превосходит сложность большинства остальных языков. Где кривая выходит на плато, не берусь судить, в рамках задачи мне его достигнуть не удалось.

Cколько в итоге придется заплатить за ублажание хаскеллевского компилятора

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

Cколько времени сэкономит знаменитое удобство горутин

Удобство имеется, но я не вижу каких-то принципиальных отличий от любого другого языка, включая раст. В том же сишарпе я могу написать Task.Run(() => DoStuffAsyncInAnotherThread()) и оно запустит гринтред, где будет что-то там вычислять. Чуть больше букв, чем go DoAsyncStuff(), но зато у них нет ограничений вроде того, что плохо вызывать горутины в контексте выполнения других горутин (у нас из-за рекурсии так получалось), их легко композировать с помощью Task.WhenAny/Task.WhenAll (в го придется руками через каналы это изображать), ну и так далее.

С другой стороны встроенный детектор дедлоков мне понравился. Без него я бы наверное ещё полчаса просидел, пытаясь понять, почему на экране пусто. Очень удобный инструмент. Хотя, как мы помним по варианту кода с черным экраном, и он работает не всегда.

Если я где-то ошибаюсь, опять же, пожалуйста, поправьте меня в комментариях.

Субъективная часть

С рациональной частью закончили, теперь давайте про личное отношение и ощущения.

В случае хаскелля у меня было понимание, что делать дальше в каждый момент времени. Состояние программы разбивается на две части: либо она компилируется, тогда ничего делать не надо, либо нет, тогда нужно исправить ошибку. Например, как в наших случаях, когда для вывода на экран у нас должно быть дерево строк, а у нас на руках дерево чисел, и тогда вопрос звучит "как преобразовать одно в другое?", или когда мы замениил Maybe на IO и гуглили "На что заменить fromJust чтобы вытаскивать не из Maybe, а из IO?".

В случае го изначально все было элементарно, т.к. он сам состоит из элементарных блоков, но то что код скомпилировался ничего ещё не означает. Мне не удалось нагуглить библиотек которые взяли бы на себя сложность с ожиданями, блокировками, распараллеливанием и всем остальным, из-за чего пришлось писать всё это самому. И ловить дедлоки. А когда у тебя дедлок, ты не можешь просто спросить гугл "что мне сделать чтобы его починить", потому что каждый случай индивидуален. Нужно либо идти в чатик и просить разобрать твой код, либо сидеть расставлять принты. Любопытный факт, что брейкпоинт на функции загрузки комментариев у меня срабатывал только один раз за работу приложения, хотя очевидно туда поток выполнения заходит для каждого узла дерева. С чем это связано не берусь судить, но если бы это работало иначе, скорее всего я бы сразу догадался в чем проблема.


Программа на хаскелле требует больше знаний, более абстрактного подхода к проектированию задачи, но оно вознаграждает за это тем, что скорее всего за тебя уже кто-то написал библиотеку, которая снимает головную боль с тебя. Нужен многопоток? Бери вон то. Нужен JSON? Бери вот это. Скомпонуй через точку или доллар и все будет хорошо. Любопытная особенность в том, что в хаскелле я не понимаю отдельных моментов (например, я до сих пор не знаю что делает оператор ^.), но зато я понимаю, как оно работает на верхнем уровне (каким-то образом достает из HTTP ответа тело), поэтому я могу его использовать.

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


Наконец, коммьюнити.

В хаскель мне вежливо объяснили как настроить IDE/окружение/etc, ну и целом просто помогали/отвечали на все на вопросы.

А вот в го чате произошла совершенно изумительная история.

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

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

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

— в go нет ни стека, ни сортеда листа, все это заменяется как слайс

— удобно *sarcasm*

Sorry, this group is no longer accessible

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

Скрытый текст

Сложность простоты - 6

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

Заключение

Чем можно все это дело подытожить? Для своего бекграунда и задач я не нашел, что мне может предоставить го. Дедлок чекер из коробки очень крут. Компилится все и правда за миллисекунды, никаких промежуточных артефактов, после сишарпа с миллионом obj/bin файликов это кажется очень странным. Но на этом такое ощущение, что преимущества заканчиваются. Мне так показалось, что плюсами Go восхищаются по большей части люди со скриптовых языков, и для которых это все очень серьезные преимущества, но для энтерпрайз-разработки это всё довольно слабо же. Люди в чате языка правда не знают, что есть способы автоматической генерации запросов с тайпчеком, или автоматической генерацией схемы и миграцией из моделей кода. В качестве супер-крутого решения в чате предлагали использовать gorm, но как разработчику знакомому с EF/Linq2Db/Hibernate/Dapper мне просто больно смотреть на его API.

Я ни в коем случае не хочу никого задеть, просто мне кажется, что люди вообще не подозревают что такие штуки уже существуют, и что ими можно пользоваться. Ведь всё это плоды "сложности" языков, которая создается не для того, чтобы усложнить всем жизнь, а наоборот, упростить. Чтобы не надо было писать SQL руками, а он генерировался. Чтобы выражать программу не императивно, а декларативно. Чтобы параллелизация заключалась в изменении одной строчки, а не переосмыслении всей программы. Да, мой пример был простой, и в сложном случае придется думать и в том, и в другом случае, но в "сложном" языке всегда есть вероятность, что кто-то решил проблему за вас. С другой стороны вы в свою очередь всегда можете написать крутое решение, которым будут пользоваться миллионы, и которое может стать настолько крутым, что заменит стандартное (из жизни раста примитивы синхронизации, а ещё хэшмап). В go вы никогда не сможете соревноваться со стандартными Map, потому что у вас нет возможности конкурировать с разработчиками стандартной библиотеки: то что можно делать им, простым смертным — не получится.

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

Сложность простоты - 7

Го — очень простой язык, но почему же на нем так сложно писать?..

Автор: Алексей

Источник


* - обязательные к заполнению поля