пятница, 2 августа 2013 г.

[prog.flame] Какое-то странное чувство прекрасного у Go-феров

На первых же слайдах презентации с ёмким названием "Twelve Go Best Practices", сделанной на конференции OSCON-2013, встретился пример, который я не могу пропустить без комментариев. Так что включаю режим стёба и перехожу к слайдам :)

На слайде #3 приводится пример "плохого" кода:

func (g *Gopher) DumpBinary(w io.Writererror {
    err := binary.Write(w, binary.LittleEndian, int32(len(g.Name)))
    if err == nil {
        _, err := w.Write([]byte(g.Name))
        if err == nil {
            err := binary.Write(w, binary.LittleEndian, g.Age)
            if err == nil {
                return binary.Write(w, binary.LittleEndian, g.FurColor)
            }
            return err
        }
        return err
    }
    return err
}

На следующем слайде объясняется, чем он плох. Оказывается, нужно избегать излишней вложенности блоков при обработке ошибок (дословно: Avoid nesting by handling errors first)...

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

func (g *Gopher) DumpBinary(w io.Writererror {
    err := binary.Write(w, binary.LittleEndian, int32(len(g.Name)))
    if err != nil {
        return err
    }
    _, err = w.Write([]byte(g.Name))
    if err != nil {
        return err
    }
    err = binary.Write(w, binary.LittleEndian, g.Age)
    if err != nil {
        return err
    }
    return binary.Write(w, binary.LittleEndian, g.FurColor)
}

Ох Ё! И чем это лучше, хотелось бы спросить? По объему ничего не выиграли. В обозримости кода так же улучшений не видно. По крайней мере в первом варианте я сразу видел, что каждая следующая операция выполняется только в случае успешного выполнения предыдущей. Во втором варианте этот факт нужно определять посредством более внимательного взгляда на каждый if.

Похоже, автор презентации сам понимает, что второй пример далеко не лучше первого, поэтому предлагает третий вариант, со вспомогательным классом и вспомогательной функцией:

type binWriter struct {
    w   io.Writer
    err error
}

// Write writes a value into its writer using little endian.
func (w *binWriter) Write(v interface{}) {
    if w.err != nil {
        return
    }
    w.err = binary.Write(w.w, binary.LittleEndian, v)
}

func (g *Gopher) DumpBinary(w io.Writererror {
    bw := &binWriter{w: w}
    bw.Write(int32(len(g.Name)))
    bw.Write([]byte(g.Name))
    bw.Write(g.Age)
    bw.Write(g.FurColor)
    return bw.err
}

Да уж, больному стало легче, он перестал дышать... Для далеких от Go читателей поясню. Теперь функция DumpBinary сначала создает вспомогательный объект, в котором хранится поток для записи двоичных данных, а так же описание последней ошибки (точнее, указатель на это описание). При создании вспомогательного объекта (с именем bw) этот указатель автоматически получает значение nil, что соответствует успешному результату записи в потом. Далее несколько раз подряд вызывается вспомогательная функция Write, которая получает указатель на вспомогательный объект bw. Функция Write сначала проверяет наличие ошибки предыдущей операции (указатель на описание ошибки в этом случае будет отличным от nil) и, если ошибок не было (bw.err == nil), производит очередную запись в поток двоичных данных. Результат успешности записи сохраняется в том же вспомогательном объекте bw, именно для этого bw передается во вспомогательную функцию Write по указателю. (Примечание. В терминах Go эта функция Write является методом для типа binWriter, а сам этот тип является приватным, т.е. не экспортируемым из пакета, т.к. его имя начинается с маленькой буквы.)

Итак, код был просто здорово улучшен! Вместо простых четырех операций записи данных в поток, мы добавили сюда еще и новый тип с методом, сохраняющим результаты своей работы модифицируя объект (сторонники функционального программирования в восторге). Код DumpBinary стал компактнее, но стал ли он проще? Например, как быстро новый разработчик, которому выпало сопровождать DumpBinary разберется, где же именно происходит обработка ошибок? И что обращения к Write(*binWriter) происходят всегда четыре раза, даже если на первом же из них произошла ошибка ввода-вывода?

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

Но автору презентации и третий вариант не удовлетворил. Он предложил четвертый. В котором вспомогательная функция Write сама разбирается с тем, какого типа аргумент ей передали. Разбирается, если я правильно понимаю, в run-time, а не в compile-time:

// Write writes a value into its writer using little endian.
func (w *binWriter) Write(v interface{}) {
    if w.err != nil {
        return
    }
    switch v.(type) {
    case string:
        s := v.(string)
        w.Write(int32(len(s)))
        w.Write([]byte(s))
    default:
        w.err = binary.Write(w.w, binary.LittleEndian, v)
    }
}

func (g *Gopher) DumpBinary(w io.Writererror {
    bw := &binWriter{w: w}
    bw.Write(g.Name)
    bw.Write(g.Age)
    bw.Write(g.FurColor)
    return bw.err
}

А теперь представим, что в тип Gopher со временем добавили еще одно поле: Pattern []byte. Что и где нужно будет менять, чтобы DumpBinary корректно сохранял новый вариант структуры? Добавление в DumpBinary еще одной строки bw.Write(g.Pattern) будет недостаточно. Нужно будет еще и добавить еще один кейс внутри select-а по типу аргумента в binWriter.Write. Причем, если мы этого не сделаем, компилятор нам по рукам не даст. Поле Pattern будет таки сериализовано, но просто как последовательность байт, без предшествующего маркера длины этой последовательности.

Ну и да, совсем маленькая мелочь. В третьем и четвертом вариантах кода есть серьезная ошибка: размер поля Name записывается в поток обычным методом Write, а не методом binary.Write с указанием binary.LittleEndian. Так что, если первый и второй варианты гарантировали, что длина Name всегда будет упакована в виде Little Endian, то третий и четвертый варианты будут это делать только в случае, если Little Endian является "родным" представлением для той платформы, на которой код работает.

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

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

func (g *Gopher) DumpBinary(w io.Writererror {
    err := binary.Write(w, binary.LittleEndian, int32(len(g.Name)))
    if err == nil {
        _, err := w.Write([]byte(g.Name))
        if err == nil {
            err := binary.Write(w, binary.LittleEndian, g.Age)
            if err == nil {
                err = binary.Write(w, binary.LittleEndian, g.FurColor)
            }
        }
    }

    return err
}

Да, есть лесенка. Но зато она очевидная, логика кода легко просматривается, т.к. он не замусорен лишними return-ами. Да и короче, чем все предложенные в презентации варианты.

Ну а на случай резкого неприятия "лесенки if-ов" достаточно вспомнить прием, который давным-давно применяется в чистых Сях для того, чтобы записывать подобные последовательности операций, каждую из которых можно выполнять только, если предыдущая завершилась успешно. Посредством goto Error :)

int DumpBinary(Gopher * g, Stream * w)
{
   int err;
   if0 != (err = binary_write_int32(w, strlen(g->Name), LITTLE_ENDIAN)) ) goto Error;
   if0 != (err = binary_write_bytes(w, g->Name, strlen(g->Name))) ) goto Error;
   if0 != (err = binary_write_int32(w, g->Age, LITTLE_ENDIAN)) ) goto Error;
   if0 != (err = binary_write_int32(w, g->FurColor, LITTLE_ENDIAN)) ) goto Error;

Error :
   return err;
}

Только вот, кажется, в Go нет goto :( Update. Оказывается, в Go есть goto. Тем более странно, что он не был зайдествован в презентации во втором примере.

Ну а в нормальных языках давно уже применяются исключения, как раз для того, чтобы лесенки из if-ов не писать. Да и шаблоны (генерики), чтобы проверки типов и поиск подходящих функций/методов были в compile-time, а не в run-time. Языки эти, правда, пообъемнее Go будут. Да и писали их не Пайк с Томпсоном, что, очевидно, является их фатальным недостатком ;)

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