вторник, 26 октября 2010 г.

[prog.flame] Впечатления от Go-шных defer, panic и recover

Фактически, это продолжение спора, завязавшегося в комментариях к заметке “Почему я не использую языки D и Go”. Прочитал о таких механизмах языка Go, как defer, panic и recover (ссылка #1 на блог разработчиков языка Go, ссылка #2 на Effective Go). Поскольку на Go я не программировал, то буду рассказывать то, что я понял. А это может быть совсем не то, что есть в действительности ;)

Итак, что же такое defer, panic и recover в Go и зачем они там? Ключевое слово defer позволяет зарегистрировать вызов функции, который должен быть сделан автоматически при выходе из текущей функции. Например, пусть мы открываем файл и должны гарантировать его закрытие при выходе из функции. Механизм defer позволяет нам это сделать просто и элегантно:

file, err := os.Open(filename, os.O_RDONLY, 0)
if err != nil {
   // Какая-то ошибка...
   // Обрабатываем ее и уходим.
   return err
}
// Ошибок нет, файл должен быть закрыт.
defer file.Close() // Теперь файл автоматически будет закрыт.

Если в текущей функции сделано несколько обращений к defer, то зарегистрированные отложенные вызовы будут произведены в обратном порядке (по аналогии с тем, как в C++ происходят вызовы деструкторов объектов). Собственно и назначение defer аналогично деструкторам C++ – очистка ресурсов.

Но, в отличии от деструкторов C++, отложенные функции в Go могут изменять возвращаемое значение той функции, в которой был зарегистрирован их отложенный вызов. Например:

func sample() (res int) {
   defer func() {
      res = 1
   }()

   return 0
}

Если вызывать sample(), то вернет она 1, а не 0, т.к. возвращаемое значение замещается в отложенной функции. Сделано это для того, чтобы можно было управлять возвращаемым значением в случае panic-ов.

Конструкция panic прерывает выполнение текущей функции и начинает “раскрутку стека”. В этом процессе вызываются лишь все зарегистрированные через defer функции (т.е. программисту дается возможность очистить ресурсы). Если процесс раскрутки стека доходит до корня текущей goroutine, то приложение аварийно завершается.

Но, в случаях, когда приложение может восстановиться после сбоя, возникший panic можно перехватить с помощью инструкции recover. Вызывать ее имеет смысл лишь в отложенных функциях. Если recover возвращает nil, то раскрутка стека сейчас не выполняется. Но если recover возвратила отличное от nil значение, значит сейчас идет раскрутка стека в результате panic. И возвратила recover как раз объект, переданный в panic. Например:

func openAndReadFileContent() (content string, err os.Error) {
   // Регистрируем отложенную функцию, которая все panic-и
   // будет преобразовывать в отрицательный ответ (если паника
   // была вызвана os.Error).
   defer func() {
      if r := recover(); r != nil {
         err = r.(os.Error) // Это, на самом-то деле, хитрая строка.
      }
   }

   file, err := os.Open(someFileName, os.O_RDONLY, 0)
   if err != nil {
      return nil, err
   }
   defer file.Close()

   content := readAndParseFileContent(file)

   return content, nil
}

func readAndParseFileContent(file File) string {
   // Выполнение чтение исодержимого файла и при каждой
   // ошибке порождение паники.
   rawContent, err := ioutil.ReadAll(file)
   if err != nil {
      panic(err)
   }
   ... // Преобразование прочитанного содержимого.
}

Здесь в начале функции openAndReadFileContent регистрируется отложенная функция, которая преобразует панику в обычное возвращаемое значение. Что позволяет писать саму функцию openAndReadFileContent (и все подчиненные ей функции) так, чтобы инициировать панику в любых ситуациях, когда что-то идет не так.

После такого короткого введения выскажу свои соображения. Фактически это те же самые исключения. Только Go-шные, а не привычные большинству мейнстримовых программистов. Можно было бы сказать, что в сущности это Фаберже, автопортрет, вид в профиль, фрагмент. Однако…

Лично мне кажется, что обычные исключения все-таки лучше.

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

Отсюда вытекает и во-вторых. А именно: вот когда разработчику использовать коды ошибок, а когда panic? Если язык программирования изначально поддерживает исключения (скажем, как Ruby), то там все просто – все библиотеки кидают исключения и ты сам пишешь в том же духе. А что здесь?

В-третьих, если все-таки panic-и начинают использоваться как исключения (например, в блоге разработчиков Go советуют посмотреть, как этот прием используется в модуле разбора JSON-а), то получается, что разработчик пишет свой аналог catch (через отложенную функцию с обращением к recover). Но если в языках с исключениями (вроде C++, Java, C#, Python, Ruby и в том же Erlang-е) это намерение разработчика явно декларируется специальной языковой конструкцией try-catсh вокруг проблемного кода, то в Go все это дело уводится в отложенный вызов (который далеко не всегда будет лямбда-функцией). Что не есть хорошо.

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

func (d *decodeState) unmarshal(v interface{}) (err os.Error) {
    defer func() {
        if r := recover(); r != nil {
            if _, ok := r.(runtime.Error); ok {
                panic(r)
            }
            err = r.(os.Error)
        }
    }()

Здесь ошибку поймали, проверили, принадлежит ли оно семейству runtime.Error. Если принадлежит, то пробросили эту ошибку дальше (посредством еще одного вызова panic). А если это не runtime.Error, то считается, что это os.Error и именно os.Error возвращается.

Как по мне, так запись (это язык Ruby):

begin
  doSomething
rescue OsError => x
  return x
end

представляется более простой, компактной и надежной.

Так что я бы предпочел иметь обычные исключения, а не Go-шные panic и recover-у. Ну, а аналог defer-а есть в том же D в виде конструкций scope(exit). Да еще и более гибкий, имхо.

PS. Кстати, на счет очень хитрой сроки err=r.(os.Error). Хитрость ее заключается в том, что это приведение типа. И если r не принадлежит типу os.Error то будет сгенерирована новая паника (это если я правильно понял документацию). Так что в связи с этим последний пример с функцией unmarshal (взятый как раз из модуля декодирования JSON-а) не кажется мне надежным.

11 комментариев:

Qrilka комментирует...

Эрланг ты зря в языки с исключениями записал :)
По факту они там есть, но намного идеоматичней возврат статус (аля ok | {error, ErrorDetails} ), а ПМ делает ненужными тупые ифы как в сях.

eao197 комментирует...

@Qrilka:

я его туда записал, поскольку:

- он уже упоминался в том флейме. И, с моей точки зрения, добавление исключений в Erlang как раз является признанием факта их полезности;

- насколько я понял по фрагментам исходников OTP, конструкции trу-catch в Erlange являются специальной разновидностью ПМ и намного больше похожи на try-catch в C++/Java/C#, чем обработка recovery в Go.

Qrilka комментирует...

А идеоматичные Эрлангу коды возврата ты просто "скипнул"? И настойчиво обсуждаешь нечасто используемый try catch? (Введённый, кстати, не с самого начала в язык)
И интересно, что за "исходники OTP" ты смотрел.
Можно ссылкой на файлик с гитхаба - http://github.com/erlang/otp/

eao197 комментирует...

>А идеоматичные Эрлангу коды возврата ты просто "скипнул"?

Не вижу смысла в теме об исключениях в Go и D говорить об кодах возврата в Эрланге.

По факту исключения в Эрланге есть? Или это миф?

В С++ исключений так же не было. И даже сейчас в C++ исключения используются не часто. Тем не менее, тенденция к увеличению их использования явная. Не исключено, что и в Эрланге процесс идет в том же направлении.

>Можно ссылкой на файлик с гитхаба - http://github.com/erlang/otp/

Да как два байта:

http://github.com/erlang/otp/blob/dev/lib/compiler/src/compile.erl -- строка 986, например. Практически чистой воды try-catch из C++/Java подобного языка.

http://github.com/erlang/otp/blob/dev/lib/asn1/src/asn1ct_gen.erl -- сколько здесь будет catch-ей.

Еще можно заглянуть в разные cos*/src/
А можно просто grep натравить и найти несколько сотен файлов, в которых try и/или catch задействован.

Qrilka комментирует...

Из первого же файла пример варианта "не по исключениям":
ok = file:close(Lf)

Я просто хотел обратить твоём внимание на то, что твои словам про "код будет пестреть if-ами" справедливы не для всех языков.

eao197 комментирует...

>Я просто хотел обратить твоём внимание на то, что твои словам про "код будет пестреть if-ами" справедливы не для всех языков.

Полностью с этим согласен. Данное утверждение я относил только к Go. Если же из текста заметки выходило, что данная фраза относилась к нескольким языкам, то это моя недоработка.

Erlang я упоминал вот почему. За счет конструкции catch в Erlang очень легко работать и с кодом, который порождает исключения, и с кодом, который использует традиционную схему с кодами возврата. Т.е. если изначально разработчик написал что-то вроде:

case some:func(...) ...

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

case catch some:func(...) ...

и всех делов.

Это дает возможность более просто применять исключения в Erlang, чем panic-и в Go.

Gaperton комментирует...

> В С++ исключений так же не было. И даже сейчас в C++ исключения используются не часто. Тем не менее, тенденция к увеличению их использования явная. Не исключено, что и в Эрланге процесс идет в том же направлении.

Исключено.

Gaperton комментирует...

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

Так что и спорить не о чем.

eao197 комментирует...

@Gaperton:

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

А заголовок поста прочитать не судьба? Речь изначально шла о впечатлениях.

>Так что и спорить не о чем.

А смысл какой? Ты можешь повлиять на развитие Go?

Я не могу, да мне это и не нужно. Так что и смысла спорить нет.

Gaperton комментирует...

Я, к сожалению, вообще не замечаю у Go какого-либо развития.

Так что влиять не на что :).

Но язык крайне интересен с теоретической точки зрения своим отношением выразительности с простоте. Такое редко встретишь.

Вот, скажем, казалось бы, эксцепшнов в нем нет. Но - тот же эффект легко достигается без и введения отдельной концепции.

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

То же самое касается классов. Их, как отдельного понятия, нет. Но...

И так далее.

eao197 комментирует...

>_Достигается_, а то, что делается это не так, как ты к этому привык - вопрос эстетики. Можно успешно аргументировать и за, и против такого способа, но косяка в _отсутствии_ исключений нет.

Полностью согласен. Но как раз из-за несоответствия моим эстетическим пристрастиям язык Go не интересен мне как потенциальный практический инструмент.

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