вторник, 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-а) не кажется мне надежным.

Отправить комментарий