пятница, 22 ноября 2019 г.

Golang puzzlers: синхронизации go-процедур (goroutine)

В этом посте рассмотрим задачу синхронизации go-процедур (goroutine). Дан код:

package main

import (
  "fmt"
)

func main() {

  hosts := []string{"ahost", "bhost", "chost"}

  for i : range hosts {
    go func(){
      fmt.Println(hosts[i])
    }()
  }
}

Вопрос: что выведет данный код? Ответ: он вообще не скомпилируется. Это видно невооруженным взглядом - здесь банальная ошибка в синтаксисе range - range для среза будет возвращать 2 значения - индекс элемента и содержимое элемента, а также оператор присваивания написан неверно - вместо := написано :. Хорошо, исправим:

package main

import (
  "fmt"
)

func main() {

  hosts := []string{"ahost", "bhost", "chost"}

  for i, _ := range hosts {
    go func(){
      fmt.Println(hosts[i])
    }()
  }
}

Вопрос: что выведет код сейчас? Ответ: ничего. Почему? Потому что запустив 3 goroutine основной поток исполнения функции main не будет ждать их выполнения и выполнит возврат, поэтому в консоль не будет ничего напечатано. Необходимо чтобы были напечатаны все три значения hosts - по одному на каждую goroutine. Попробуем исправить: здесь у нас появляется выбор, поскольку получить такой результат можно разными способами. Рассмотрим использование sync.WaitGroup:

package main

import (
  "fmt"
  "sync"
)

func main() {

  var wg sync.WaitGroup

  hosts := []string{"ahost", "bhost", "chost"}

  for i, _ := range hosts {
    wg.Add(1)
    go func() {
      fmt.Println(hosts[i])
      wg.Done()
    }()
  }
  wg.Wait()
}

Вопрос: что выведет код сейчас? Ответ: три раза chost. Почему? Потому что каждая goroutine, хотя и является замыканием и может использовать переменную i, определенную вне анонимной функции, запускаемой как go-процедура (goroutine), но не запоминает значения i, а на момент исполнения каждой из go-процедур цикл range уже завершился и в i сохранилось последнее значение 2, поэтому все go-процедуры печатают значение hosts[2]. Исправим, чтобы получить значение i, которое было во время запуска go-процедуры:

package main

import (
  "fmt"
  "sync"
)

func main() {
  var wg sync.WaitGroup

  hosts := []string{"ahost", "bhost", "chost"}

  for i, _ := range hosts {
    wg.Add(1)
    go func(i int) {
      fmt.Println(hosts[i])
      wg.Done()
    }(i)
  }
  wg.Wait()
}

Теперь будут выведены все 3 значения hosts. go-процедуры будут выполняться конкурентно, но порядок вывода не гарантируется. Похожий на первый взгляд результат мы можем получить используя для синхронизации небуферизованный канал:

package main

import (
  "fmt"
)

func main() {

  // Создаем небуферизованный канал
  ch := make(chan int)

  hosts := []string{"ahost", "bhost", "chost"}

  for i, _ := range hosts {
    go func(i int) {
      fmt.Println(hosts[i])
      // Получаем сообщение из канала
      <-ch
    }(i)
    // Отправляем 0 в канал
    ch <- 0
  }

}

Теперь также будут выведены все 3 значения hosts. За счет того что прием из небуферизованного канала происходит до завершения отправки по небуферизованному каналу мы гарантируем что последняя go-процедура будет выполнена и все значения будут напечатаны.

Но у данного подхода есть проблема - мы потеряли конкурентность в исполнении go-процедур - теперь они запускаются последовательно и каждая следующая go-процедура, ждет завершения предыдущей, поскольку в range цикле запись в небуферизованный канал может быть выполнена только когда канал пуст. Можно вернуть конкурентность, для этого используем буферизованный канал с размером равным размеру hosts - в данному случае кроме конкурентности мы сможем также получить сохранение последовательности вывода в консоль значений hosts, поскольку значения i будут записаны в канал в порядке их итерации. Для ожидания завершения всех go-процедур используем другой буферизованный канал out размером равным размеру hosts. С помощью утверждения select считываем из него значения и подсчитываем количество вернувшихся значений либо ожидаем 1 мс и снова проверяем содержимое канала:

package main

import (
  "fmt"
  "time"
)

func main() {

  hosts := []string{"ahost", "bhost", "chost"}
  size := len(hosts)

  ch := make(chan int, size)
  out := make(chan int, size)

  for i, _ := range hosts {
    ch <- i
    go func() {
      fmt.Println(hosts[<-ch])
      out <- 0
    }()
  }
  var count int
  for {
    select {
    case <-out:
      count++
      if count == size {
        return
      }
    default:
      time.Sleep(1 * time.Millisecond)
    }
  }
}

Теперь мы получили то, что предполагал данный пример изначально: конкурентное исполнение go-процедур с сохранением порядка запуска.

Запустить пример в песочнице play.golang.org


Читайте также:


четверг, 21 ноября 2019 г.

Golang puzzlers: длина строки

Пример этого поста: определение длины строки. Дан код:

package main

import "fmt"

func main() {
  test := "frйday"
  fmt.Println(len(test))
}

Вопрос: что выведется при запуске?

На первый взгляд покажется что ответ должен быть 6, поскольку в строке 6 символов. Но при запуске в выводе будет 7!

В чем подвох? Дело в том что len измеряет строку по байтам, а не по символам. 5 символов frday являются ASCII символами и в UTF-8 (а исходный код всех строк в Go представлен UTF-8 текстом) каждый из этих символов занимает 1 байт, а символ й не является ASCII символом, и в UTF-8 занимает 2 байта, поэтому 5+2=7. Стоит сразу отметить, что исходный код строк в Go состоит из UTF-8 текста, но в UTF-8 если символ вмещается в 1 байт (как с ASCII символами), то он и записывается 1 байтом. Символ й занимает 2 байта, но если бы мы взяли какой-нибудь китайский иероглиф, который в UTF-8 занимает 3 байта, в нашем примере, то могли бы получить даже 8 в ответе, например:

package main

import "fmt"

func main() {
  test := "fr語day"
  fmt.Println(len(test))
}

Вывод:

8

Запустить пример в песочнице play.golang.org

Для более подробной информации о строках, символах и кодировке читайте этот пост.


Читайте также:


Golang puzzlers: возврат ошибки

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

Пример этого поста. Дан код:

package main

import (
 "fmt"
)

type myType struct {
 msg string
}

func (m *myType) Error() string {
 return m.msg
}

func test() *myType {
 return nil
}

func main() {
 var err error
 err = test()
 if err != nil {
  fmt.Println("occured error")
  return
 }
 fmt.Println("ok")
}

Вопрос: что выведет данный код?

На первый взгляд все очевидно: задан тип myType, указатель на который реализует интерфейс error. Раз указатель на myType реализует интерфейс ошибки, то кажется что все верно - функция test возвращает указатель на myType, а в функции main возвращаемое значение будет записано в переменную типа error. При запуске программа скомпилируется и кажется, что раз мы возвращаем nil в test, то будет выведено ok, но в консоль выведется occured error!

Начнем разбор - распечатаем переменную err, которая должна быть равна nil:

package main

import (
 "fmt"
)

type myType struct {
 msg string
}

func (m *myType) Error() string {
 return m.msg
}

func test() *myType {
 return nil
}

func main() {
 var err error
 err = test()
 if err != nil {
  fmt.Println("occured error")
  fmt.Println(err)
  return
 }
 fmt.Println("ok")
}

При запуске получим:

occured error
<nil>

Значит err равно nil? В чем подвох? Мы сможем понять это использовав пакет reflect для разбора:

package main

import (
 "fmt"
 "reflect"
)

type myType struct {
 msg string
}

func (m *myType) Error() string {
 return m.msg
}

func test() *myType {
 return nil
}

func main() {
 var err error
 err = test()
 if err != nil {
  fmt.Println("error")
  fmt.Println(err)
  e := reflect.ValueOf(err)
  fmt.Println(e)
  fmt.Println(e.Kind())
  return
 }
 fmt.Println("ok")
 fmt.Println(err)
}

При запуске получим:

error
<nil>
<nil>
ptr

Что получается? В err записывается указатель со значением nil. Это указатель на тип myType, который реализует интерфейс error, поэтому этот указатель можно записать в переменную типа error - компилятор позволит нам это. Но при проверке на nil за nil засчитается только значение записанное по типу интерфейса error - то есть если бы функция test в качестве возвращаемого значения в сигнатуре функции указывала бы интерфейс error, тогда все бы работало как предполагается и в консоль было бы выведено ok.

package main

import (
 "fmt"
)

type myType struct {
 msg string
}

func (m *myType) Error() string {
 return m.msg
}

func test() error {
 return nil
}

func main() {
 var err error
 err = test()
 if err != nil {
  fmt.Println("error")
  return
 }
 fmt.Println("ok")
}

При запуске получим:

ok

Запустить пример в песочнице play.golang.org


Читайте также:


четверг, 14 ноября 2019 г.

Работа с JSON в Golang

JSON (JavaScript Object Notation) - это простой формат обмена данными. Синтаксически это напоминает объекты и списки JavaScript. Он чаще всего используется для связи между веб-интерфейсами и программами JavaScript, работающими в браузере, но также используется и во многих других местах. Его домашняя страница, json.org, предоставляет четкое и краткое определение стандарта.

С пакетом json легко и быстро читать и записывать данные JSON из ваших программ Go.

Кодирование

Для кодирования данных в JSON используется функция Marshal.

func Marshal(v interface{}) ([]byte, error)

Задав структуру данных Go, Message,

type Message struct {
    Name string
    Body string
    Time int64
}

и экземпляр сообщения

m := Message{"Alice", "Hello", 1294706395881547000}

мы можем получить JSON-кодированную версию m, используя json.Marshal:

b, err := json.Marshal(m)

Если все хорошо, err будет nil, а b будет []byte, содержащим JSON данные:

b == []byte(`{"Name":"Alice","Body":"Hello","Time":1294706395881547000}`)

Будут закодированы только структуры данных, которые могут быть представлены валидным JSON:

  • Объекты JSON поддерживают только строки в качестве ключей; чтобы кодировать Go тип map он должен иметь вид map[string]T (где T - любой тип Go, поддерживаемый пакетом json).
  • Channel, complex и func типы не могут быть закодированы.
  • Циклические структуры данных не поддерживаются; они приведут к попаданию Marshal в бесконечный цикл.
  • Указатели будут закодированы как значения, на которые они указывают (или 'null', если указатель равен nil).

Пакет json обращается только к экспортированным полям struct типов (те, которые начинаются с заглавной буквы). Поэтому в выводе JSON будут присутствовать только экспортированные поля структуры.

Декодирование

Для декодирования JSON данных используем функцию Unmarshal.

func Unmarshal(data []byte, v interface{}) error

Сначала нужно создать место, где будут храниться декодированные данные

var m Message

и вызвать json.Unmarshal, передав ему []byte JSON данных и указатель на m

err := json.Unmarshal(b, &m)

Если b содержит допустимый JSON, который соответствует m, после вызова err будет nil и данные из b будут сохранены в структуре m, как если бы это было сделано с помощью присваивания, подобного следующему:

m = Message{
    Name: "Alice",
    Body: "Hello",
    Time: 1294706395881547000,
}

Как Unmarshal идентифицирует поля для хранения декодированных данных? Для заданного ключа JSON "Foo" Unmarshal просматривает поля структуры назначения, чтобы найти (в порядке предпочтения):

  • Экспортированное поле с тегом "Foo"
  • Экспортированное поле с именем "Foo"
  • Экспортированное поле с именем "FOO" или "FoO" или другое нечувствительное к регистру совпадение "Foo"

Что происходит, когда структура данных JSON не совсем соответствует типу Go?

b := []byte(`{"Name":"Bob","Food":"Pickle"}`)
var m Message
err := json.Unmarshal(b, &m)

Unmarshal будет декодировать только те поля, которые он может найти в типе назначения. В этом случае будет заполнено только поле Name в m, а поле Food будет игнорироваться. Это поведение особенно полезно, когда вы хотите выбрать только несколько определенных полей из большого JSON-объекта. Это также означает, что Unmarshal не затронет любые неэкспортированные поля в структуре назначения.

Но что, если вы заранее не знаете структуру данных JSON?

Универсальный JSON с interface{}

Тип interface{} (пустой интерфейс) описывает интерфейс с нулевыми методами. Каждый тип Go реализует как минимум ноль методов и поэтому удовлетворяет пустому интерфейсу.

Пустой интерфейс служит общим типом контейнера:

var i interface{}
i = "a string"
i = 2011
i = 2.777

Утверждение типа обращается к подлежащему конкретному типу:

r := i.(float64)
fmt.Println("the circle's area", math.Pi*r*r)

Или, если базовый тип неизвестен, переключатель типа определяет тип:

switch v := i.(type) {
case int:
    fmt.Println("twice i is", v*2)
case float64:
    fmt.Println("the reciprocal of i is", 1/v)
case string:
    h := len(v) / 2
    fmt.Println("i swapped by halves is", v[h:]+v[:h])
default:
    // i ни один из типов выше
}

Пакет json использует значения map[string]interface{} и []interface{} для хранения произвольных объектов и массивов JSON; он с радостью разархивирует любой допустимый большой бинарный объект JSON (blob) в простое значение interface{}. Конкретные типы Go по умолчанию:

  • bool для логических выражений JSON
  • float64 для JSON чисел
  • string для JSON строк
  • nil для JSON null

Декодирование произвольных данных

Рассмотрим JSON данные, хранящиеся в переменной b:

b := []byte(`{"Name":"Wednesday","Age":6,"Parents":["Gomez","Morticia"]}`)

Не зная структуры этих данных, мы можем декодировать их в значение interface{} с помощью Unmarshal:

var f interface{}
err := json.Unmarshal(b, &f)

В этот момент значением Go в f будет карта, ключи которой являются строками, а сами значения хранятся как пустые значения интерфейса:

f = map[string]interface{}{
    "Name": "Wednesday",
    "Age":  6,
    "Parents": []interface{}{
        "Gomez",
        "Morticia",
    },
}

Чтобы получить доступ к этим данным, мы можем использовать утверждение типа для доступа к подлежащему map[string]interface{}:

m := f.(map[string]interface{})

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

for k, v := range m {
    switch vv := v.(type) {
    case string:
        fmt.Println(k, "is string", vv)
    case float64:
        fmt.Println(k, "is float64", vv)
    case []interface{}:
        fmt.Println(k, "is an array:")
        for i, u := range vv {
            fmt.Println(i, u)
        }
    default:
        fmt.Println(k, "is of a type I don't know how to handle")
    }
}

Таким образом, вы можете работать с неизвестными JSON данными, сохраняя при этом преимущества безопасности типов.

Ссылочные типы

Определим тип Go, который будет содержать данные из предыдущего примера:

type FamilyMember struct {
    Name    string
    Age     int
    Parents []string
}

var m FamilyMember
err := json.Unmarshal(b, &m)

Демонстрация этих данных в значение FamilyMember работает, как и ожидалось, но если мы посмотрим внимательнее, то увидим, что произошла замечательная вещь. С помощью оператора var мы выделили структуру FamilyMember, а затем предоставили указатель на это значение для Unmarshal, но в то время поле Parents было нулевым значением среза. Чтобы заполнить поле Parents, Unmarshal выделил новый срез за кулисами. Это типично для того, как Unmarshal работает с поддерживаемыми ссылочными типами (указатели, срезы и карты).

Рассмотрим конвертирование в эту структуру данных:

type Foo struct {
    Bar *Bar
}

Если бы в объекте JSON было поле Bar, Unmarshal выделил бы новый Bar и заполнил его. Если нет, Bar будет оставлен как нулевой указатель.

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

type IncomingMessage struct {
    Cmd *Command
    Msg *Message
}

и отправляющая сторона может заполнить поле Cmd и/или поле Msg объекта JSON верхнего уровня, в зависимости от типа сообщения, которое они хотят передать. Unmarshal при декодировании JSON в структуру IncomingMessage будет выделять только структуры данных, присутствующие в JSON данных. Чтобы узнать, какие сообщения обрабатывать, программисту просто нужно проверить, что Cmd или Msg не ноль.

Потоковые кодировщики и декодеры

Пакет json предоставляет типы Decoder и Encoder для поддержки обычной операции чтения и записи потоков данных JSON. Функции NewDecoder и NewEncoder оборачивают типы интерфейсов io.Reader и io.Writer.

func NewDecoder(r io.Reader) *Decoder
func NewEncoder(w io.Writer) *Encoder

Вот пример программы, которая читает серию объектов JSON из стандартного ввода, удаляет все, кроме поля Name, из каждого объекта, а затем записывает объекты в стандартный вывод:

package main

import (
    "encoding/json"
    "log"
    "os"
)

func main() {
    dec := json.NewDecoder(os.Stdin)
    enc := json.NewEncoder(os.Stdout)
    for {
        var v map[string]interface{}
        if err := dec.Decode(&v); err != nil {
            log.Println(err)
            return
        }
        for k := range v {
            if k != "Name" {
                delete(v, k)
            }
        }
        if err := enc.Encode(&v); err != nil {
            log.Println(err)
        }
    }
}

Из-за повсеместного распространения программ чтения и записи эти типы кодировщиков и декодеров могут использоваться в широком диапазоне сценариев, таких как чтение и запись в соединения HTTP, веб-сокеты или файлы.

Пример использования тегов в структуре

Использование тегов в структуре кодируемой в JSON позволяет получить названия полей в результирующем JSON, отличающиеся от названия полей в структуре. В следующем примере в результирующем JSON поле BrandID будет выглядеть как brand_id:

package main

import (
 "encoding/json"
 "fmt"
)

type Item struct {
 ID      uint   `json:"id"`
 Title   string `json:"title"`
 BrandID uint   `json:"brand_id"`
}

func main() {

 item := Item{ID: 1, Title: "Car", BrandID: 1}
 jitem, err := json.Marshal(item)
 if err != nil {
  fmt.Println(err.Error())
  return
 }
 fmt.Println(string(jitem))
}

Вывод:

{"id":1,"title":"Car","brand_id":1}

Запустить пример в песочнице play.golang.org


Читайте также:


понедельник, 11 ноября 2019 г.

Паттерны в Golang: Fan-Out

Fan-Out - это шаблон обмена сообщениями, используемый для распределения работы между работниками.

Можно смоделировать Fan-Out, используя каналы Go.

// Разделить канал на n каналов, 
// которые получают сообщения в циклическом порядке.
func Split(ch <-chan int, n int) []<-chan int {

  // Создаем пул из n каналов
  cs := make([]chan int)
  for i := 0; i < n; i++ {
    cs = append(cs, make(chan int))
  }

  // Распределяет работу в круговом порядке 
  // среди указанного числа каналов, 
  // пока основной канал не будет закрыт. 
  // При закрытии основного канала закрывает 
  // все каналы и возвращается.
  toChannels := func(ch <-chan int, cs []chan<- int) {

    // Закрываем каждый канал, 
    // когда выполнение заканчивается.
    defer func(cs []chan<- int) {
      for _, c := range cs {
        close(c)
      }
    }(cs)

    // Направляем сообщения из
    // основного канала ch
    // в каналы из пула cs
    for {
      for _, c := range cs {
        select {
        case val, ok := <-ch:
          if !ok {
            return
          }

          c <- val
        }
      }
    }
  }

  go toChannels(ch, cs)

  return cs
}

Функция `Split` преобразует один канал в список каналов с помощью goroutine для копирования полученных значений по каналам в списке в циклическом порядке.


Читайте также:


среда, 6 ноября 2019 г.

Паттерны в Golang: Fan-In

Fan-In - это паттерн обмена сообщениями, используемый для создания воронки для работы среди исполнителей. Источником сообщений могут быть клиенты, а место назначения - сервер.

Мы можем смоделировать Fan-In паттерн используя каналы Go.

// Объединяем разные каналы в один канал
func Merge(cs ...<-chan int) <-chan int {
  var wg sync.WaitGroup

  out := make(chan int)

  // Запускаем send goroutine 
  // для каждого входящего канала в cs. 
  // send копирует значения из c в out 
  // до тех пор пока c не закрыт, затем вызываем wg.Done.
  send := func(c <-chan int) {
    for n := range c {
      out <- n
    }
    wg.Done()
  }

  wg.Add(len(cs))
  for _, c := range cs {
    go send(c)
  }

  // Запускаем goroutine чтобы закрыть out 
  // когда все send goroutine выполнены
  // Это должно начаться после вызова wg.Add.
  go func() {
    wg.Wait()
    close(out)
  }()
  return out
}

Функция `Merge` преобразует список каналов в один канал, запуская goroutine для каждого входящего канала, которая копирует значения в единственный исходящий канал.

Как только все send goroutine были запущены, запускается отдельная goroutine с функцией, ожидающей завершения всех запущенных, чтобы закрыть основной канал.


Читайте также:


понедельник, 4 ноября 2019 г.

Golang в Google: дизайн языка на службе разработки программ, часть 1

Rob Pike, 2012

Язык программирования Go был задуман в конце 2007 года как ответ на некоторые проблемы, с которыми мы сталкивались при разработке программной инфраструктуры в Google. Сегодняшний вычислительный ландшафт практически не связан с той средой, в которой были созданы используемые языки, в основном C++, Java и Python. Проблемы, создаваемые многоядерными процессорами, сетевыми системами, массивными вычислительными кластерами и моделью веб-программирования, скорее были решены как обходные пути, чем решены как задуманные изначально. Более того, масштаб изменился: современные серверные программы состоят из десятков миллионов строк кода, работают с сотнями или даже тысячами программистов и обновляются буквально каждый день. Что еще хуже, время сборки, даже на больших кластерах компиляции, увеличилось до многих минут, даже часов.

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

В этой статье объясняется, как были решены эти проблемы при создании эффективного, компилируемого языка программирования, который кажется легким и приятным. Примеры и объяснения будут взяты из реальных проблем, с которыми сталкиваются в Google.

1. Введение

Go - это компилируемый, конкурентный, имеющий сборщик мусора, статически типизированный язык, разработанный в Google. Это проект с открытым исходным кодом: Google импортирует публичный репозиторий, а не наоборот.

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

2. Go в Google

Go - это язык программирования, разработанный Google для решения проблем Google, и у Google большие проблемы.

Аппаратное обеспечение большое, а программное обеспечение большое. Есть много миллионов строк программного обеспечения, с серверами в основном на C++ и множеством Java и Python для других частей. Тысячи инженеров работают над кодом, находящимся в "голове" одного дерева, включающего все программное обеспечение, поэтому со дня на день происходят значительные изменения на всех уровнях дерева. Большая специализированная распределенная система сборки делает разработку в таком масштабе осуществимой, но она все еще медлительная.

И, конечно же, все это программное обеспечение работает на миллионах компьютеров, которые рассматриваются как небольшое количество независимых сетевых вычислительных кластеров.

Кратко, разработка в Google велика, может быть медленной и часто неуклюжей. Но это эффективно.

Цели проекта Go состояли в том, чтобы устранить медлительность и неуклюжесть разработки программного обеспечения в Google и тем самым сделать процесс более продуктивным и масштабируемым. Язык был разработан для людей, которые пишут, читают, отлаживают и поддерживают большие программные системы.

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

Но как язык может помочь в разработке программного обеспечения? Остальная часть этой статьи является ответом на этот вопрос.

3. Болевые точки

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

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

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

В качестве простого, автономного примера рассмотрим представление структуры программы. Некоторые наблюдатели возражали против C-образной блочной структуры Go с фигурными скобками, предпочитая использовать пробелы для отступов в стиле Python или Haskell. Тем не менее, у нас был большой опыт отслеживания сбоев сборки и тестирования, вызванных межъязыковыми сборками, где фрагмент Python, встроенный в другой язык, например, посредством вызова SWIG, тонко и незаметно нарушается изменением отступа окружающего кода. Поэтому наша позиция такова: хотя места для отступов хороши для небольших программ, они плохо масштабируются, и чем больше и разнороднее кодовая база, тем больше проблем это может вызвать. Лучше отказаться от удобства из-за безопасности и надежности, поэтому у Go есть ограниченные скобками блоки.

4. Зависимости в C и C++

Более существенная иллюстрация масштабирования и других проблем возникает при обработке зависимостей пакетов. Мы начнем обсуждение с обзора того, как они работают в C и C++.

Стандарт ANSI C, впервые принятый в 1989 году, продвигал идею #ifndef "охраняет" ("guards") в стандартных заголовочных файлах. Идея, которая сейчас распространена повсеместно, заключается в том, что каждый заголовочный файл заключен в скобки с условным блоком компиляции, чтобы файл мог быть включен несколько раз без ошибок. Например, заголовочный файл Unix <sys/stat.h> схематически выглядит так:

/* Крупная заметка об авторском праве и лицензии */
#ifndef _SYS_STAT_H_
#define _SYS_STAT_H_
/* Типы и другие определения */
#endif

Предполагается, что препроцессор C читает файл, но игнорирует содержимое во втором и последующих чтениях файла. Символ _SYS_STAT_H_, определяемый при первом чтении файла, "охраняет" ("guards") последующие вызовы.

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

Но это очень плохо масштабируется.

В 1984 году компиляция ps.c, исходного кода команды Unix ps, #include <sys/stat.h> наблюдалась 37 раз к тому времени, когда была выполнена вся предварительная обработка. Хотя при этом содержимое отбрасывается 36 раз, большинство реализаций C открывают файл, читают его и сканируют все 37 раз. Фактически, без большой хитрости это поведение требуется потенциально сложной макросемантикой препроцессора С.

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

Технически говоря, так не должно быть. Понимая долгосрочные проблемы с использованием средств защиты #ifndef, разработчики библиотек Plan 9 выбрали другой, не-ANSI подход. В Plan 9 было запрещено содержать в заголовочных файлах дополнительные пункты #include; все #include должны были находиться в файле C верхнего уровня. Конечно, это требовало некоторой дисциплины - программист должен был перечислять необходимые зависимости ровно один раз, в правильном порядке - но документация помогла, и на практике это сработало очень хорошо. В результате, независимо от того, сколько зависимостей имел исходный файл C, каждый файл #include читался ровно один раз при компиляции этого файла. И, конечно же, было легко увидеть, нужен ли был #include, убрав его: отредактированная программа скомпилируется только тогда, когда зависимость не понадобится.

Наиболее важным результатом подхода Plan 9 была гораздо более быстрая компиляция: объем ввода-вывода, который требуется для компиляции, может быть значительно меньше, чем при компиляции программы с использованием библиотек с защитой #ifndef.

Однако вне Plan 9 "guards" подход является общепринятой практикой для C и C++. Фактически, C++ усугубляет проблему, используя тот же подход с более высокой степенью детализации. По соглашению, программы на C++ обычно структурированы с одним заголовочным файлом на класс или, возможно, небольшим набором связанных классов, группировка намного меньше, чем, скажем, <stdio.h>. Поэтому дерево зависимостей намного сложнее, отражая не библиотечные зависимости, а полную иерархию типов. Более того, заголовочные файлы C++ обычно содержат реальный код - объявления типа, метода и шаблона, а не только простые константы и сигнатуры функций, типичные для заголовочного файла C. Таким образом, C++ не только отправляет больше компилятору, но и то, что он отравляет, сложнее компилировать, и каждый вызов компилятора должен повторно обрабатывать эту информацию. При создании большого бинарного файла C++, компилятор мог бы тысячи раз научиться представлять строку, обрабатывая заголовочный файл <string>. (Для сведения, около 1984 года Том Каргилл заметил, что использование препроцессора C для управления зависимостями будет долгосрочным обязательством для C++ и должно быть решено.)

Создание одного бинарного файла C++ в Google может открывать и читать сотни отдельных заголовочных файлов десятки тысяч раз. В 2007 году инженеры по сборке в Google разработали сборку основного бинарного файла Google. Файл содержал около двух тысяч файлов, которые, если их просто объединить, составили 4,2 мегабайта. Ко времени расширения #include на вход компилятора было доставлено более 8 гигабайт, что составляет 2000 байт на каждый исходный байт C++.

В качестве другой точки данных, в 2003 году система сборки Google была перемещена из одного файла Makefile в дизайн для каждого каталога с более управляемыми, более явными зависимостями. Типичный бинарный файл сократился примерно на 40% по размеру файла, просто из-за более точной записи зависимостей. Несмотря на это, свойства C++ (или C в этом отношении) делают непрактичным автоматическую проверку этих зависимостей, и сегодня у нас все еще нет точного понимания требований к зависимостям больших бинарных файлов Google C++.

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

Даже с распределенной системой сборки большая сборка Google может занять много минут. Этот бинарный файл 2007 года занял 45 минут с использованием системы распределенной сборки предшественника; сегодняшняя версия той же программы занимает 27 минут, но, конечно, программа и ее зависимости за это время выросли. Инженерные усилия, необходимые для масштабирования системы сборки, едва ли могли опередить рост создаваемого программного обеспечения.


Читайте также:


Golang в Google: дизайн языка на службе разработки программ, часть 3

Rob Pike, 2012

Продолжение, начало в части 1 и части 2.

9. Синтаксис

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

Таким образом, Go был разработан с ясностью и удобством и имеет чистый синтаксис. По сравнению с другими языками в семействе C его грамматика скромна по размеру: всего 25 ключевых слов (C99 - 37; C++ 11 - 84; цифры продолжают расти). Что еще более важно, грамматика является правильной и поэтому легко разбирается (в основном; есть несколько причуд, которые мы могли бы исправить, но не обнаружили достаточно рано). В отличие от C и Java и особенно C++, Go может быть проанализирован без информации о типе или таблицы символов; нет конкретного типа контекста. Грамматику легко разъяснить, и поэтому инструменты легко писать.

Одна из деталей синтаксиса Go, которая удивляет программистов на С, заключается в том, что синтаксис объявления ближе к Паскалю, чем к С. Объявленное имя появляется перед типом, и есть еще ключевые слова:

var fn func([]int) int
type T struct { a, b int }

по сравнению с C

int (*fn)(int[]);
struct T { int a, b; }

Объявления, введенные с помощью ключевого слова, легче анализировать как для людей, так и для компьютеров, и синтаксис типа, не являющийся синтаксисом выражения, как в C, оказывает значительное влияние на синтаксический анализ: он добавляет грамматику, но устраняет неоднозначность. Но есть и хороший побочный эффект: для инициализации объявлений можно удалить ключевое слово var и просто взять тип переменной из выражения. Эти два объявления эквивалентны; второе короче и идиоматичнее:

var buf *bytes.Buffer = bytes.NewBuffer(x) // явное
buf := bytes.NewBuffer(x)                  // производное

Синтаксис функции прост для простых функций. В этом примере объявляется функция Abs, которая принимает одну переменную x типа T и возвращает единственное значение float64:

func Abs(x T) float64

Метод - это просто функция со специальным параметром, его получателем, который можно передать функции, используя стандартную запись "точка". Синтаксис объявления метода помещает получателя в скобки перед именем функции. Вот та же самая функция, теперь как метод типа T:

func (x T) Abs() float64

А вот переменная (замыкание) с аргументом типа T; Go имеет функции первого класса и замыкания:

negAbs := func(x T) float64 { return -Abs(x) }

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

func ReadByte() (c byte, err error)

c, err := ReadByte()
if err != nil { ... }

Мы поговорим об ошибках позже.

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

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

10. Именование

Go использует необычный подход к определению видимости идентификатора, возможность для клиента пакета использовать элемент, названный идентификатором. В отличие, например, от private и public ключевых слов, в Go само имя содержит информацию: регистр начальной буквы идентификатора определяет видимость. Если начальный символ является заглавной буквой, идентификатор экспортируется (общедоступный); в противном случае это не так:

  • заглавная буква: Name видно клиентам пакета
  • в противном случае: name (или _Name) не видно клиентам пакета

Это правило распространяется на переменные, типы, функции, методы, константы, поля - на все. Это все, что нужно сделать.

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

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

Другое упрощение заключается в том, что Go имеет очень компактную иерархию областей видимости:

  • всеобъемлящая (universe) (предварительно объявленные идентификаторы, такие как int и string)
  • пакет (все исходные файлы пакета находятся в одной области видимости)
  • файл (только для импорта пакетов переименовывает; на практике это не очень важно)
  • функция (обычная)
  • блок (обычный)

Нет пространства для пространства имен, класса или другой оберточной конструкции. Имена происходят из очень немногих мест в Go, и все имена соответствуют одной и той же иерархии областей видимости: в любом заданном месте в исходном коде идентификатор обозначает ровно один языковой объект, независимо от того, как он используется. (Единственное исключение - метки утверждений, цели утверждений break и т.п.; они всегда имеют область видимость функции.)

Это имеет последствия для ясности. Обратите внимание, например, что методы объявляют явного получателя и что он должен использоваться для доступа к полям и методам типа. Там нет неявного this. То есть всегда пишут

rcvr.Field

(где rcvr - это любое имя, выбранное для переменной-получателя), поэтому все элементы типа всегда отображаются лексически связанными со значением типа-получателя. Точно так же квалификатор пакета всегда присутствует для импортированных имен; всегда пишут io.Reader, а не Reader. Это не только ясно, но и освобождает идентификатор Reader как полезное имя для использования в любом пакете. На самом деле в стандартной библиотеке есть несколько экспортируемых идентификаторов с именем Reader или Printf, но какой из них упоминается, всегда однозначно.

Наконец, эти правила объединяются, чтобы гарантировать, что кроме предопределенных имен верхнего уровня, таких как int, (первый компонент) каждое имя всегда объявляется в текущем пакете.

Кратко, имена локальные. В C, C++ или Java имя y может относиться к чему угодно. В Go y (или даже Y) всегда определяется внутри пакета, в то время как интерпретация x.Y ясна: найдите x локально, Y принадлежит ему.

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

Следует упомянуть еще один аспект именования: поиск метода всегда осуществляется только по имени, а не по сигнатуре (типу) метода. Другими словами, у одного типа никогда не может быть двух методов с одинаковым именем. Учитывая метод x.M, существует только один M, связанный с x. Опять же, это позволяет легко определить, к какому методу относится только имя. Это также упрощает реализацию вызова метода.

11. Семантика

Семантика операторов Go обычно C-подобна. Это компилируемый, статически типизированный, процедурный язык с указателями и так далее. По замыслу он должен чувствовать себя знакомым программистам, привыкшим к языкам в семействе С. При запуске нового языка важно, чтобы целевая аудитория могла быстро его выучить; укоренение Go в семействе C помогает молодым программистам, большинство из которых знают Java, JavaScript и, возможно, C, находить Go легким в освоении.

Тем не менее, Go вносит много небольших изменений в семантику C, в основном для обеспечения надежности. Они включают:

  • нет арифметики указателей
  • нет неявных числовых преобразований
  • границы массива всегда проверяются
  • нет псевдонимов типов (после типа X int, X и int являются разными типами, а не псевдонимами)
  • ++ и -- являются утверждениями, а не выражениями
  • присваивание не является выражением
  • законно (даже рекомендуется) брать адрес стековой переменной
  • и многое другое

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

  • конкурентности
  • сборки мусора
  • типов интерфейсов
  • отражения (reflection)
  • переключателей типа

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


Читайте также:


Golang в Google: дизайн языка на службе разработки программ, часть 4

Rob Pike, 2012

Продолжение, начало в части 1, части 2, части 3.

12. Конкурентность

Конкурентность важна для современной вычислительной среды с ее многоядерными машинами, на которых работают веб-серверы с несколькими клиентами, что можно назвать типичной программой Google. Этот вид программного обеспечения не особенно хорошо обслуживается C++ или Java, которым не хватает достаточной поддержки конкурентности на уровне языка.

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

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

Получившийся язык позволяет нам плавно соединить конкурентность с вычислениями. Рассмотрим веб-сервер, который должен проверять сертификаты безопасности для каждого входящего вызова клиента; в Go легко сконструировать программное обеспечение, используя CSP для управления клиентами как независимо выполняемыми процедурами, но при этом использовать все возможности эффективного компилируемого языка, доступного для дорогостоящих криптографических вычислений.

Таким образом, CSP является практичным для Go и для Google. При написании веб-сервера, канонической программы Go, модель отлично подходит.

Есть одно важное предостережение: Go не является полностью безопасным для памяти при наличии конкурентности. Совместное использование разрешено, а указатель на канал является идиоматическим (и эффективным).

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

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

13. Сборка мусора

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

Это было простое решение, потому что управление памятью сильно влияет на то, как язык работает на практике. В C и C++ слишком много программистских усилий тратится на выделение и освобождение памяти. Получающиеся проекты имеют тенденцию раскрывать детали управления памятью, которые вполне могут быть скрыты; наоборот, соображения памяти ограничивают то, как их можно использовать. В отличие от этого, сборка мусора упрощает определение интерфейсов.

Более того, в конкурентном объектно-ориентированном языке практически необходимо иметь автоматическое управление памятью, поскольку владение частью памяти может быть сложным в управлении, так как она передается между конкурентными выполнениями. Важно отделить поведение от управления ресурсами.

Язык намного проще в использовании из-за сборки мусора.

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

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

Ключевым моментом является то, что Go предоставляет программисту инструменты для ограничения выделения путем управления макетом структур данных. Рассмотрим определение простого типа структуры данных, содержащей буфер (массив) байтов:

type X struct {
    a, b, c int
    buf [256]byte
}

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

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

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

Чтобы дать программисту такую ​​гибкость, Go должен поддерживать то, что мы называем внутренними указателями на объекты, расположенные в куче. Поле X.buf в приведенном выше примере находится внутри структуры, но допустимо захватить адрес этого внутреннего поля, например, чтобы передать его в процедуру ввода-вывода. В Java, как и во многих языках со сборщиком мусора, невозможно создать внутренний указатель, подобный этому, но в Go это идиоматично. Эта точка проектирования влияет на то, какие алгоритмы сбора данных можно использовать, и может усложнить их, но после тщательного обдумывания мы решили, что необходимо разрешить внутренние указатели из-за преимуществ для программиста и способности снизить нагрузку (возможно, сложнее реализовать) на сборщик. Пока что наш опыт сравнения похожих программ на Go и Java показывает, что использование внутренних указателей может оказать существенное влияние на общий размер арены, задержку и время сбора.

Таким образом, Go является языком со сборщиком мусора, но дает программисту некоторые инструменты для управления издержками сбора.

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


Читайте также:


Golang в Google: дизайн языка на службе разработки программ, часть 5

Rob Pike, 2012

Продолжение, начало в части 1, части 2, части 3, части 4.

14. Композиция, а не наследование

Go использует необычный подход к объектно-ориентированному программированию, позволяя использовать методы для любого типа, не только для классов, но без какой-либо формы наследования на основе типов, такой как создание подклассов. Это означает, что нет иерархии типов. Это был намеренный выбор дизайна. Хотя иерархии типов использовались для создания очень успешного программного обеспечения, мы считаем, что эта модель была чрезмерно использована и что стоит сделать шаг назад.

Вместо этого у Go есть интерфейсы, идея, которая далее кратко описана.

В Go интерфейс - это просто набор методов. Например, вот определение интерфейса Hash из стандартной библиотеки.

type Hash interface {
    Write(p []byte) (n int, err error)
    Sum(b []byte) []byte
    Reset()
    Size() int
    BlockSize() int
}

Все типы данных, которые реализуют эти методы, неявно удовлетворяют этому интерфейсу; нет implements декларации. Тем не менее, удовлетворенность интерфейса статически проверяется во время компиляции, поэтому отвязывающие (decoupling) интерфейсы безопасны для типов.

Тип обычно удовлетворяет многим интерфейсам, каждый из которых соответствует подмножеству своих методов. Например, любой тип, который удовлетворяет интерфейсу Hash, также удовлетворяет интерфейсу Writer:

type Writer interface {
    Write(p []byte) (n int, err error)
}

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

Объектно-ориентированное программирование обеспечивает ясное понимание: поведение данных может быть обобщено независимо от представления этих данных. Модель работает лучше всего, когда поведение (набор методов) фиксировано, но как только вы подклассифицируете тип и добавляете метод, поведения больше не идентичны. Если вместо этого набор поведений фиксирован, например, в статически определенных интерфейсах Go, то единообразие поведения позволяет составлять данные и программы равномерно, ортогонально и безопасно.

Одним из крайних примеров является ядро ​​Plan 9, в котором все элементы данных системы реализованы точно в одном интерфейсе, API файловой системы, определенный 14 методами. Это единообразие допускало уровень композиции объектов, редко достигаемый в других системах, даже сегодня. Примеров предостаточно. Вот один из них: система может импортировать (в терминологии Plan 9) стек TCP на компьютер, который не имеет TCP или даже Ethernet, и через эту сеть подключиться к машине с другой архитектурой процессора, импортировать свое дерево /proc и запустить локальный отладчик для отладки точки останова удаленного процесса. Такая операция была обычным делом для Plan 9, ничего особенного. Способность делать такие вещи выпала из дизайна; это не требовало особой договоренности (и все было сделано на простом C).

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

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

Рассмотрим интерфейс Writer, показанный выше, который определен в пакете io: любой элемент, имеющий метод Write с этой сигнатурой, хорошо работает с дополнительным интерфейсом Reader:

type Reader interface {
    Read(p []byte) (n int, err error)
}

Эти два взаимодополняющих метода позволяют создавать безопасные цепочки с богатым поведением, как обобщенные каналы Unix. Файлы, буферы, сети, шифраторы, компрессоры, кодировщики изображений и т.д. Могут быть соединены вместе. Процедура ввода-вывода в формате Fprintf использует io.Writer, а не, как в C, FILE*. Отформатированный принтер не знает, о чем пишет; это может быть кодировщик изображения, который, в свою очередь, пишет в компрессор, который, в свою очередь, пишет в шифратор, который, в свою очередь, пишет в сетевое соединение.

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

Также обратите внимание, что устранение иерархии типов также устраняет форму иерархии зависимостей. Удовлетворение интерфейса позволяет программе расти органично без заранее определенных контрактов. И это линейная форма роста; изменение интерфейса влияет только на непосредственных клиентов этого интерфейса; нет поддерева для обновления. Отсутствие implements деклараций беспокоит некоторых людей, но позволяет программам расти естественно, изящно и безопасно.

Интерфейсы Go оказывают большое влияние на дизайн программы. Мы видим это в одном месте - использование функций, которые принимают аргументы интерфейса. Это не методы, это функции. Некоторые примеры должны иллюстрировать их силу. ReadAll возвращает срез байтов (массив), содержащий все данные, которые можно прочитать из io.Reader:

func ReadAll(r io.Reader) ([]byte, error)

Оболочки (wrappers) - функции, которые принимают интерфейс и возвращают интерфейс - также широко распространены. Вот несколько прототипов. LoggingReader регистрирует каждый вызов Read на входящем Reader. LimitingReader прекращает чтение после n байтов. ErrorInjector помогает тестировать, имитируя ошибки ввода/вывода. И еще много других.

func LoggingReader(r io.Reader) io.Reader
func LimitingReader(r io.Reader, n int64) io.Reader
func ErrorInjector(r io.Reader) io.Reader

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

15. Ошибки

Go не имеет средств исключений в общепринятом смысле, то есть нет структуры управления, связанной с обработкой ошибок. (Go предоставляет механизмы для обработки исключительных ситуаций, таких как деление на ноль. Пара встроенных функций, называемых panic и recover, позволяет программисту защищаться от таких вещей. Однако эти функции намеренно неуклюжи, редко используются и не интегрированы в библиотеки так, как, скажем, исключения интегрированы в библиотеки Java.)

Ключевой особенностью языка для обработки ошибок является предопределенный тип интерфейса, называемый error, который представляет значение, которое имеет метод Error, возвращающий строку:

type error interface {
    Error() string
}

Библиотеки используют тип error, чтобы вернуть описание ошибки. В сочетании с возможностью для функций возвращать несколько значений, легко вернуть вычисленный результат вместе со значением ошибки, если оно есть. Например, эквивалентный getchar в C не возвращает внеполосное значение в EOF и не генерирует исключение; он просто возвращает значение ошибки рядом с символом, при этом нулевое значение ошибки означает успех. Вот сигнатура метода ReadByte типа bufio.Reader буферизованного пакета ввода-вывода:

func (b *Reader) ReadByte() (c byte, err error)

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

Это был осознанный выбор не включать исключения в Go. Хотя ряд критиков не согласен с этим решением, есть несколько причин, по которым мы полагаем, что оно улучшает программное обеспечение.

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

f, err := os.Open(fileName)
if err != nil {
    return err
}

Кроме того, если ошибки используют специальные структуры управления, обработка ошибок искажает поток управления для программы, которая обрабатывает ошибки. Java-стиль блоков try-catch-finally чередует несколько перекрывающихся потоков управления, которые взаимодействуют сложным образом. Несмотря на то, что Go, напротив, делает более подробным проверку ошибок, явный дизайн обеспечивает прямой контроль над процессом - буквально.

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

16. Инструменты

Программная инженерия требует инструментов. Каждый язык работает в среде с другими языками и множеством инструментов для компиляции, редактирования, отладки, профилирования, тестирования и запуска программ.

Синтаксис Go, система пакетов, соглашения об именах и другие функции были разработаны для упрощения написания инструментов, а библиотека включает лексер, анализатор и средство проверки типов для языка.

Инструменты для манипулирования программами Go настолько просты в написании, что было создано много таких инструментов, некоторые из которых имеют интересные последствия для разработки программного обеспечения.

Наиболее известным из них является gofmt, форматировщик исходного кода Go. С самого начала проекта мы планировали форматировать программы Go на компьютере, исключая целый класс споров между программистами: как мне выложить свой код? Gofmt запускается на всех программах Go, которые мы пишем, и большая часть сообщества с открытым исходным кодом также использует его. Он запускается как "предварительная проверка" для репозиториев кода, чтобы удостовериться, что все отмеченные в Go программы отформатированы одинаково.

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

Gofmt включил другой класс инструментов, которые мы не могли предвидеть так же ясно. Программа работает, анализируя исходный код и переформатируя его из самого дерева разбора. Это позволяет редактировать дерево разбора перед его форматированием, поэтому появился набор инструментов автоматического рефакторинга. Их легко написать, они могут быть семантически богатыми, потому что они работают непосредственно в дереве разбора и автоматически генерируют канонически отформатированный код.

Первым примером был флаг -r (rewrite) на самом gofmt, который использует простой язык сопоставления с образцом для включения перезаписей на уровне выражения. Например, однажды мы ввели значение по умолчанию для правой части выражения среза: сама длина. Все исходное дерево Go было обновлено, чтобы использовать это значение по умолчанию с помощью одной команды:

gofmt -r 'a[b:len(a)] -> a[b:]'

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

Подобный, но более сложный процесс позволил использовать gofmt для обновления дерева, когда язык больше не требовал точек с запятой в качестве завершителей операторов, если оператор заканчивался символом новой строки.

Другим важным инструментом является gofix, который запускает модули переписывания дерева, написанные на самом Go, которые, следовательно, способны к более сложному рефакторингу. Инструмент gofix позволил нам внести радикальные изменения в API и языковые функции, приведшие к релизу Go 1, включая изменение синтаксиса для удаления записей с карты, радикально отличающийся API для манипулирования значениями времени и многое другое. Когда эти изменения были введены, пользователи могли обновить весь свой код, выполнив простую команду

gofix

Обратите внимание, что эти инструменты позволяют нам обновлять код, даже если старый код все еще работает. В результате, репозитории Go легко обновляются по мере развития библиотек. Старые API могут быть устаревшими быстро и автоматически, поэтому необходимо поддерживать только одну версию API. Например, мы недавно изменили реализацию буфера протокола Go, чтобы использовать функции "getter", которых раньше не было в интерфейсе. Мы запустили gofix для всего кода Go в Google, чтобы обновить все программы, использующие буферы протокола, и теперь используется только одна версия API. Подобные радикальные изменения в библиотеках C++ или Java практически невозможны в масштабах кодовой базы Google.

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

Хотя подобные инструменты редко упоминаются в контексте языкового проектирования, они являются неотъемлемой частью экосистемы языка, и тот факт, что Go был разработан с учетом инструментов, оказывает огромное влияние на развитие языка, его библиотек и его сообщества.

Заключение

Использование Go растет внутри Google.

Его используют несколько крупных пользовательских служб, в том числе youtube.com и dl.google.com (сервер загрузки, который обеспечивает загрузку Chrome, Android и других загрузок), а также наш собственный golang.org. И, конечно же, многие маленькие делают это, в основном, с использованием встроенной поддержки Google App Engine для Go.

Многие другие компании также используют Go; список очень длинный, но некоторые из наиболее известных:

  • BBC Worldwide
  • Canonical
  • Heroku
  • Nokia
  • SoundCloud

Похоже, что Go достигает своих целей. Тем не менее, еще рано говорить об успехе. У нас пока нет достаточного опыта, особенно с большими программами (миллионы строк кода), чтобы узнать, окупились ли попытки построить масштабируемый язык. Хотя все показатели положительные.

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

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

Однако не все было исправлено. Мы все еще учимся (но язык пока зафиксирован).

Существенным недостатком языка является то, что реализация все еще нуждается в работе. Сгенерированный код компилятора и производительность среды выполнения, в частности, должны быть лучше, и работа над ними продолжается. Уже есть прогресс; на самом деле, некоторые тесты показывают удвоение производительности в версии для разработчиков сегодня (конец 2012 года) по сравнению с первым выпуском Go версии 1 в начале 2012 года.

Резюме

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

Свойства, которые привели к этому, включают:

  • ясные зависимости
  • ясный синтаксис
  • ясная семантика
  • композиция, а не наследование
  • простота, обеспечиваемая моделью программирования (сборка мусора, конкурентность)
  • легкие инструменты (инструмент go, gofmt, godoc, gofix)

Если вы еще не попробовали Go, мы советуем вам это сделать.


Читайте также:


Golang в Google: дизайн языка на службе разработки программ, часть 2

Rob Pike, 2012

Продолжение, начало в части 1

5. Ввод Go

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

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

  • Он должен работать в масштабе для больших программ с большим количеством зависимостей, над которыми работают большие команды программистов.
  • Это должно быть знакомо, грубо говоря C-подобно. Программисты, работающие в Google, находятся в начале своей карьеры и наиболее знакомы с процедурными языками, особенно из семьи C. Необходимость быстрого повышения производительности труда программистов на новом языке означает, что язык не может быть слишком радикальным.
  • Это должно быть современно. C, C++ и в некоторой степени Java довольно старые, разработанные до появления многоядерных машин, современных сетей и разработки веб-приложений. Существуют особенности современного мира, которые лучше соответствуют новым подходам, таким как встроенная конкурентность.

На этом фоне давайте посмотрим на дизайн Go с точки зрения разработки программного обеспечения.

6. Зависимости в Go

Так как мы подробно рассмотрели зависимости в C и C++, хорошее место для начала нашего тура - посмотреть, как Go их обрабатывает. Зависимости определяются синтаксически и семантически языком. Они явные, понятные и "вычислимые", то есть простые в написании инструментов для анализа.

Синтаксис заключается в том, что после предложения package (тема следующего раздела) каждый исходный файл может иметь один или несколько утверждений импорта, включающих ключевое слово import и строковую константу, идентифицирующую пакет, который будет импортирован в этот исходный файл (только):

import "encoding/json"

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

Есть еще один шаг, на этот раз в реализации компиляторов, который идет еще дальше, чтобы гарантировать эффективность. Рассмотрим программу Go с тремя пакетами и граф зависимостей:

  • пакет А импортирует пакет В
  • пакет B импортирует пакет C
  • пакет А не импортирует пакет С

Это означает, что пакет A использует C только транзитивно через использование B; то есть никакие идентификаторы из C не упоминаются в исходном коде для A, даже если некоторые из элементов, которые A использует из B, упоминают C. Например, пакет A может ссылаться на тип структуры, определенный в B, который имеет поле с типом определенным в C, но этот A не ссылается на себя. В качестве мотивирующего примера представьте, что A импортирует отформатированный пакет B ввода/вывода, который использует реализацию буферизованного ввода/вывода, предоставленную C, но что A сам не вызывает буферизованный ввод/вывод.

Чтобы собрать эту программу, сначала компилируется C; зависимые пакеты должны быть собраны до пакетов, которые зависят от них. Затем B компилируется; наконец, A компилируется, и затем программа может быть связана.

Когда A компилируется, компилятор читает объектный файл для B, а не его исходный код. Этот объектный файл для B содержит всю информацию о типе, необходимую для выполнения компилятором

import "B"

пункта в исходном коде для A. Эта информация включает в себя любую информацию о C, которая понадобится клиентам B во время компиляции. Другими словами, когда B компилируется, сгенерированный объектный файл содержит информацию о типе для всех зависимостей B, которые влияют на открытый интерфейс B.

Этот дизайн имеет тот важный эффект, что когда компилятор выполняет утверждение import, он открывает ровно один файл, объектный файл, идентифицируемый строкой в ​​утверждении import. Это, конечно, напоминает подход Plan 9 C (в отличие от ANSI C) к управлению зависимостями, за исключением того, что компилятор записывает файл заголовка при компиляции исходного файла Go. Однако этот процесс более автоматический и даже более эффективный, чем в Plan 9 C: данные, считываемые при оценке импорта, представляют собой просто "экспортированные" данные, а не общий исходный код программы. Влияние на общее время компиляции может быть огромным и масштабироваться по мере роста базы кода. Время на выполнение графа зависимостей и, следовательно, на компиляцию может быть экспоненциально меньше, чем в модели "включая включаемый файл" C и C++.

Стоит отметить, что этот общий подход к управлению зависимостями не оригинален; идеи восходят к 1970-м годам и распространяются через такие языки, как Modula-2 и Ada. В семействе C Java имеет элементы этого подхода.

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

Такой подход к управлению зависимостями является единственной основной причиной того, что компиляции Go выполняются быстрее, чем компиляции на C или C++. Другим фактором является то, что Go помещает данные экспорта в объектный файл; некоторые языки требуют, чтобы автор писал или компилятор генерировал второй файл с этой информацией. Это вдвое больше файлов, чтобы открыть. В Go есть только один файл, который нужно открыть для импорта пакета. Кроме того, подход с одним файлом означает, что данные экспорта (или заголовочный файл в C/C++) никогда не могут устареть относительно объектного файла.

Для записи, мы измерили компиляцию большой программы Google, написанной на Go, чтобы увидеть, как разветвление исходного кода сравнивается с анализом C++, сделанным ранее. Мы обнаружили, что это примерно 40Х, что в пятьдесят раз лучше, чем в C++ (а также проще и, следовательно, быстрее в обработке), но все же больше, чем мы ожидали. Для этого есть две причины. Во-первых, мы обнаружили ошибку: компилятор Go генерировал значительное количество данных в разделе экспорта, которые там не обязательно должны быть. Во-вторых, в данных экспорта используется подробная кодировка, которая может быть улучшена. Мы планируем решить эти проблемы.

Тем не менее, выполнение задачи в пятьдесят раз превращает минуты в секунды, перерывы на кофе превращаются в интерактивные сборки.

Еще одна особенность графа зависимостей Go - отсутствие циклов. Язык определяет, что в графе не может быть циклического импорта, и компилятор и компоновщик оба проверяют, что они не существуют. Хотя они иногда полезны, круговой импорт создает значительные проблемы в масштабе. Они требуют, чтобы компилятор имел дело с большими наборами исходных файлов одновременно, что замедляет инкрементные сборки. Более того, когда это разрешено, по нашему опыту, такой импорт приводит к тому, что огромные полосы дерева исходных кодов запутываются в большие фрагменты, которыми трудно управлять независимо, раздувая бинарные файлы и усложняя инициализацию, тестирование, рефакторинг, релиз и другие задачи разработки программного обеспечения.

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

При разработке стандартной библиотеки большие усилия были потрачены на контроль зависимостей. Может быть лучше скопировать небольшой код, чем использовать большую библиотеку для одной функции. (Тест в сборке системы жалуется, если возникают новые основные зависимости.) Гигиена зависимостей препятствует повторному использованию кода. Одним из примеров этого на практике является то, что (низкоуровневый) net пакет имеет свою собственную процедуру преобразования целочисленных значений в десятичные, чтобы избежать зависимости от большого и имеющего много зависимостей отформатированного пакета ввода-вывода. Другой заключается в том, что пакет преобразования строк strconv имеет частную реализацию определения 'печатаемых' символов, а не вытягивает большие таблицы классов символов Юникода; то, что strconv соблюдает стандарт Unicode, проверяется тестами пакета.

7. Пакеты

Проект системы пакетов Go объединяет некоторые свойства библиотек, пространств имен и модулей в единую конструкцию.

Каждый исходный файл Go, например "encoding/json/json.go", начинается с утверждения package, например:

package json

где json - это "имя пакета", простой идентификатор. Имена пакетов обычно лаконичны.

Чтобы использовать пакет, импортирующий исходный файл идентифицирует его по пути пакета в утверждении import. Значение "путь пакета" не определено языком, но на практике и условно это разделенный слешем путь к каталогу с исходным файлом пакета в репозитории:

import "encoding/json"

Затем имя пакета (в отличие от пути) используется для определения элементов из пакета в импортируещем исходном файле:

var dec = json.NewDecoder(reader)

Этот дизайн обеспечивает ясность. Всегда можно сказать, является ли имя локальным для пакета по его синтаксису: Name vs. pkg.Name (Подробнее об этом позже.)

В нашем примере путь к пакету - "encoding/json", а имя пакета - "json". Вне стандартного репозитория соглашение должно помещать имя проекта или компании в корень пространства имен:

import "google/base/go/log"

Важно понимать, что пути к пакетам уникальны, но для имен пакетов таких требований нет. Путь должен однозначно идентифицировать импортируемый пакет, а имя - это соглашение о том, как клиенты пакета могут ссылаться на его содержимое. Имя пакета не обязательно должно быть уникальным и может быть переопределено в каждом исходном файле импорта путем предоставления локального идентификатора в утверждении import. Следующие два оба импортируют эталонные пакеты, которые называют себя package log, но для импорта их в один исходный файл один из них должен быть (локально) переименован:

import "log"                          // Standard package
import googlelog "google/base/go/log" // Google-specific package

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

Другой пример: в базе кода Google много server пакетов.

8. Удаленные пакеты

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

Вот как можно использовать пакет doozer с github. Команда go get использует инструмент go build для извлечения репозитория с сайта и его установки. После установки его можно импортировать и использовать как любой обычный пакет.

$ go get github.com/4ad/doozer // Shell команда для получения пакета

import "github.com/4ad/doozer" // import утверждение doozer клиентом

var client doozer.Conn         // использование клиентом пакета

Стоит отметить, что команда go get рекурсивно загружает зависимости, свойство стало возможным только потому, что зависимости явные. Кроме того, выделение пространства путей импорта делегируется URL-адресам, что делает децентрализованным и, следовательно, масштабируемым именование пакетов в отличие от централизованных реестров, используемых другими языками.


Читайте также: