пятница, 4 мая 2018 г.

[prog.flame] Язык Go как следствие деградации софтостроения или спусковой крючок для оной?

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

  • динамические языки, вроде Python/Ruby. В пользу Go здесь работает статическая типизация и гораздо большая скорость работы;
  • сложные и тяжеловесные управляемые статически-типизированные и объектно-ориентированные Java и C#, а так же различные аналоги для JVM и .NET: Gosu, Ceylon, Kotlin, Scala, F# и пр. В пользу Go здесь работает простота (я бы сказал примитивизм, но для политкорректности пусть будет простота) и легковестность (все приложение на Go будет "весить" всего несколько мегабайт без дополнительных зависимостей, чего не скажешь о JVM, где только JRE -- это полтора-два десятка мегабайт);
  • опасные (чистый C) и сложные (C++) нативные языки. За Go здесь выступают безопасность, простота и продвинутая экосистема, при относительно небольшом проигрыше в скорости работы;
  • различная экзотика, вроде Rust-а (хайп вокруг которого проходит, а по реальной востребованности Rust очень далеко от Go, C, C++ и прочих мейнстримовых языков), Ada (кто вообще сейчас имеет представление об этом языке?), D (мало кто верит, что D еще жив), FreePascal и пр. Go гораздо более раскручен и известен, чем все это вместе взятое. Да еще и с Google за спиной.

Но, с другой стороны, не зря же говорят, что "простота хуже воровства". Программирование на Go, как мне кажется, напоминает "закат Солнца вручную". Все цели достигаются за счет длинных простыней пусть и простого, но однотипного кода. Такое ощущение, что Go создан для решения проблем методом грубой силы. Мол, если мы не можем нанять 100 классных программистов, каждый из которых напишет всего по 1K строк хорошего, но сложного кода, мы наймем 1000 обезьянок, умеющих стучать по клавиатуре, каждый из них напишет по 10K примитивного кода, но задача будет успешно решена. Причем, результат будет работать (см. выше про безопасность) и, более того, будет работать с приемлемой скоростью (см. выше про скорость работы Go-шного кода).

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

Ну вот чтобы далеко не ходить за примерами: вчера на OpenNET-е увидел новость о системе gVisor от Google. Уже удивился. Как по мне, то системы гипервизоров должны разрабатываться на языках, которые гораздо лучше сочетают выразительную мощь и скорость исполнения. Например, C++ или D. Ну или Rust, раз уж он есть. Но вот Go?..

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

func setupConsole(socketPath string) (*os.File, error) {
   // Create a new pty master and slave.
   ptyMaster, ptySlave, err := pty.Open()
   if err != nil {
      return nil, fmt.Errorf("error opening pty: %v", err)
   }
   defer ptyMaster.Close()

   // Get a connection to the socket path.
   conn, err := net.Dial("unix", socketPath)
   if err != nil {
      ptySlave.Close()
      return nil, fmt.Errorf("error dial socket %q: %v", socketPath, err)
   }
   uc, ok := conn.(*net.UnixConn)
   if !ok {
      ptySlave.Close()
      return nil, fmt.Errorf("connection is not a UnixConn: %T", conn)
   }
   socket, err := uc.File()
   if err != nil {
      ptySlave.Close()
      return nil, fmt.Errorf("error getting file for unix socket %v: %v", uc, err)
   }

   // Send the master FD over the connection.
   msg := unix.UnixRights(int(ptyMaster.Fd()))
   if err := unix.Sendmsg(int(socket.Fd()), []byte("pty-master"), msg, nil0); err != nil {
      ptySlave.Close()
      return nil, fmt.Errorf("error sending console over unix socket %q: %v", socketPath, err)
   }
   return ptySlave, nil
}

Думаю, что разработчикам, которым доводилось иметь со мной дело, уже понятно, что мне не нравится. Это множественные вызовы ptySlave.Close(). Их тут четыре штуки.

Вот такое повторение однообразных действие -- это явный признак плохого кода. По крайней мере так меня учили. И так я сам учил людей много лет. А вот код от Google громко говорит о том, что такие как я -- это отставшие от жизни старые пердуны, которые нихера не знают, как сейчас пишут код в крупнейших корпорациях, куда отбирают лучших из лучших, где отлично поставлены процессы, высокие требования к качеству и т.д. :(

Чем плох подобный код с повторениями? Во-первых, тем, что ты можешь забыть написать ptySlave.Close() в одном из if-ов сразу при написании кода. Во-вторых, ты можешь забыть это сделать при расширении функции в будущем. В-третьих, тебе нужно будет проделать больше работы, если в будущем логику этой функции придется изменить.

Т.е. проблема с дублированием ptySlave.Close() в том, что это дублирование увеличивает объем работы. Как сейчас, так и в будущем. Но, повторю тезис из начала поста: при разработке на Go проблемы решаются методом грубой силы, поэтому на увеличение объема работы особо внимания не обращают. Просто увеличивают количество рабочих рук (см. выше про простоту изучения Go).

На мой рабоче-крестьянский взгляд, необходимость дублировать ptySlave.Close() напрямую связана с примитивизмом Go. Вот просто недостаточно в Go выразительных возможностей для того, чтобы сделать что-то вроде RAII, как в C++, D или Rust. Это в C++ мы бы озадачились созданием какой-то обертки, которая бы вызвала Close автоматически. Например, попробовали бы задействовать unique_ptr:

auto [master_handle, slave_handle] = pty::open();
std::unique_ptr<pty_handle, decltype(pty::close)> ptyMaster{master_handle, pty::close};
std::unique_ptr<pty_handle, decltype(pty::close)> ptySlave{slave_handle, pty::close};
...
return ptySlave.release();

Но это в древнем, убогом, допотопном и небезопасном C++ людям приходится думать о всяких там RAII и сокращении количества кода. Ибо если об этом в C++ не подумать, то можно отстрелить себе не просто ногу, а обе ноги, а еще и вместе с яйцами. Другое дело -- простой и безопасный Go. Зачем в Go такими вещами заморачиваться?

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

func setupConsole(socketPath string) (*os.File, error) {
   // Create a new pty master and slave.
   ptyMaster, ptySlave, err := pty.Open()
   if err != nil {
      return nil, fmt.Errorf("error opening pty: %v", err)
   }
   defer ptyMaster.Close()

   // Get a connection to the socket path.
   conn, err := net.Dial("unix", socketPath)
   if err == nil {
      uc, ok := conn.(*net.UnixConn)
      if ok {
         socket, err := uc.File()
         if err == nil {
            // Send the master FD over the connection.
            msg := unix.UnixRights(int(ptyMaster.Fd()))
            if err := unix.Sendmsg(int(socket.Fd()), []byte("pty-master"), msg, nil0); err == nil {
               return ptySlave, nil
            }
            else {
               err = fmt.Errorf("error sending console over unix socket %q: %v", socketPath, err)
            }
         }
         else {
            err = fmt.Errorf("error getting file for unix socket %v: %v", uc, err)
         }
      }
      else {
         err = fmt.Errorf("connection is not a UnixConn: %T", conn)
      }
   }
   else {
      err = fmt.Errorf("error dial socket %q: %v", socketPath, err)
   }

   ptySlave.Close()
   return nil, err
}

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

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

std::tuple<File*, Error*> setupConsole(string socketPath) {
   auto [masterHandle, slaveHandle, err] = pty::open();
   if(err)
      return {nullptr, err};
   std::unique_ptr<File *, decltype(pty::close)> ptyMaster{masterHandle, pty::close};

   const auto onError = [&](auto errMaker) -> std::tuple<File*, Error*> {
      pty::close(slaveHandle);
      return {nullptr, errMaker()};
   };

   auto [conn, err2] = net::dial("unix", socketPath);
   if(err2)
      return onError([&]{ return fmt::Errorf("error dial socket {}: {}", socketPath, err2); });

   auto [uc, ok] = conn->cast_to<UnixConn>();
   if(!ok)
      return onError([&]{ return fmt::Errorf("connection is not a UnixConn: {}", conn); });

   auto [socket, err3] = uc->File();
   if(err3)
      return onError([&]{ return fmt::Errorf("error getting file for unix socket {}: {}", uc, err3); });

   ...

   return {slaveHandle, nullptr};
}

Однако, повторюсь, как только я пытаюсь заглянуть в какой-то большой Go-шный проект, я там сразу вижу куски копипасты, которую можно было бы вынести в повторно-используемые сущности. Но на это никто не заморачивается. Хер знает, что тому причиной: убогость языка или отсутствие должных языковых возможностей (вроде шаблонов/генериков или исключений или АлгТД+паттерн-матчинга). Сейчас вот заглянул в первый попавшийся файл в gVisor и увидел множественные повторения ptySlave.Close(). За пару недель до того, заглядывал в исходники Netflix-ового Titus-а и там в первом же Go-шном файле (уже не помню в каком именно) так же обнаружились куски кода, удивительно похожие друг на друга. Т.е. копипаста.

Ну а теперь, после столь продолжительной преамбулы, пора перейти к самой амбуле. Когда я начинал программировать где-то лет 30 назад, тогда было поверие, что разработка софта с каждым днем все усложняется и усложняется. Программы должны уметь делать больше, код нужно писать быстрее. Посему нормальным выглядело, когда создавались все более и более сложные инструменты. Да, освоить Turbo Pascal 5.0 было немного сложнее, чем Turbo Pascal 3.0, т.к. там появились новые сущности в виде модулей. Но, с другой стороны, модули помогли бороться с ростом объема программ. Turbo Pascal 5.5 и 6.0 осваивать было еще сложнее, т.к. там уже появилась поддержка ООП. Но ведь ООП существенно упростило работу программиста в те времена. Такая же история была и с переходом от C к первым версиям C++. Затем от первых версий C++ к C++98. Даже с Java, которая создавалась как гораздо более простая в использовании альтернатива C++, произошла похожая история.

Но вот в XXI-ом веке "что-то пошло не так". Широкое применение такого убогого инструмента, как Go (собственно, сюда бы я еще и Erlang записал и, может быть, JavaScript), говорит о том, что в разработке ПО произошел какой-то фундаментальный сдвиг. Уже сейчас можно наблюдать то, что для многих задач не нужны сложные инструменты, напротив, нужны самые простые. И, возможно, эта тенденция будет только усиливаться.

В связи с этим иногда возникает вопрос: язык Go стал всего лишь следствием этого фундаментального сдвига? Т.е. гениальный Роб Пайк сидя в Google и наблюдая за потугами лучших в мире программистов, вдруг понял: "Мир изменился! Теперь нужен не C++ и даже не Java. Нужен современный Basic, в самой простой его форме". И, в итоге, создал то, что с радостью подхватили сотни тысяч разработчиков по всему миру.

Или же произошло наоборот? Роб Пайк сделал инструмент для своего видения разработки софта в Google. Но сотни тысяч разработчиков по всему миру посмотрели на это и решили: а чем мы хуже? Нам тоже лень думать и изобретать решения, которые позволяют написать 10 строк кода вместо 100. Мы лучше напишем 100 строк вместо 10, зато ни о чем не думая. Тем более не думая о будущем и стоимости поддержки объемного простого кода. Тем более, что рубить бабло нужно здесь и сейчас, а не когда-то там, в абстрактном будущем.

Лично я думаю, что Роб Пайк гениально уловил новый тренд. И Google с Go смогли этот тренд оседлать.

Впрочем, важно не это. А то, что Go -- это всерьез и надолго. Да и вообще, развитие индустрии разработки софта наводит на мысли о приближающемся трындеце, после которого программистам старой школы, вроде меня, уже нечего будет ловить на этом празднике жизни ;)

PS. Кстати, шаблоны/генерики в Go не нужны. Ну ведь это же всем известно, правда? Как не начнется какой-нибудь срач вокруг Go, обязательно в нем окажется несколько персонажей, которые будут доказывать, что полезность шаблонов/генериков преувеличена и на практике они вообще не нужны. Тем интереснее в репозитории gVisor-а было обнаружить вот это. Двадцать первый век на дворе, ёптыть. Шаблоны, если кто не в курсе, в промышленном языке программирования, предназначенном для массового использования, появились тридцать пять лет назад. В языке Ada. Тридцать пять, Карл! Лет. Назад.

Комментариев нет: