Компилятор и виртуальная машина

В данном разделе рассматривается код организация и работа кода в директории packages/script, который относится к компиляции и работе виртуальной машины языка Simvolio. Документация предназначена в первую очередь для разработчиков.

Работа с контрактами организована следующим образом: контракты, функции пишутся на языке Simvolio и хранятся в таблицах contracts в экосистемах. При запуске программы происходит чтение исходного кода из базы данных и компиляция его в байт-код. При добавлении или изменении контрактов и записи их в блокчейн, обновляемые данные компилируются и добавляется/обновляется соответствующий байт-код в виртуальной машине. Физически байт код никуда не сохраняется, при выходе из программы и новом запуске компиляция происходит заново. Виртуальная машина представляет из себя набор объектов - контракты, функции, типы и т.п. Весь исходный код, описанный в таблице contracts у всех экосистем, компилируется в строгой последовательности в одну виртуальную машину и на всех нодах состояние виртуальной машины одно и тоже. При вызове контракта сама виртуальная машина никак не изменяет своего состояния. Любое выполнение контракта или вызов функции происходят на отдельном runtime стеке, который создается при каждом внешнем вызове. Каждая экосистема может иметь так называемую виртуальную экосистему, которая работает со своими таблицами вне блокчейна, в рамках одной ноды, и напрямую не может оказывать влияния на блокчейн или другие виртуальные экосистемы. В этом случае нода, которая хостит такую виртуальную экосистему, компилирует её контракты и создает для неё свою виртуальную машину.

Виртуальная машина

Рассмотрим, как организована виртуальная машина в памяти.

type VM struct {
   Block
   ExtCost func(string) int64
   FuncCallsDB map[string]struct{}
   Extern bool
}
  • Block - это самая главная структура, которая содержит всю информацию.
  • ExtCost - функция, которая возвращает стоимость выполнения внешних golang функций.
  • FuncCallsDB - map имен golang функций, которые возвращают стоимость выполнения первым параметром. Это функции работы с БД, которые вычисляют стоимость с помощью EXPLAIN.
  • Extern - при создании ВМ устанавливается в true и при компиляции кода не требует наличия вызываемого контракта. То есть дает вызывать контракт, который будет определен в дальнейшем.

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

func my() {
     if true {
          while false {
               ...
           }
     }
}

создает блок с функцией, в котором блок с if и в котором, в свою очередь, блок с while.

type Block struct {
    Objects map[string]*ObjInfo
    Type int
    Owner *OwnerInfo
    Info interface{}
    Parent *Block
    Vars []reflect.Type
    Code ByteCodes
    Children Blocks
}
  • Objects - map внутренних объектов типа указателей на ObjInfo. Если, к примеру, в блоке имеется переменная, то мы можем быстро получить информацию о ней по имени.
  • Type - тип блока, функций и контрактов равен ObjFunc и ObjContract.
  • Owner - ссылка на структуру OwnerInfo, которая содержит информацию о владельце компилируемого контракта. Указывается при компиляции контрактов или получается при загрузке из таблице contracts.
  • Info - содержит непосредственно информацию об объекте и зависит от типа блока.
  • Parent - указатель на родительский блок.
  • Vars - массив с типами переменных данного блока.
  • Code - непосредственно байт-код, который начнет исполнятся при передаче управления данном блоку. Например, в случае вызова функции или тела цикла.
  • Children - массив дочерних блоков. Например, вложенные функции, циклы, условные операторы.

Рассмотрим еще одну важную структуру ObjInfo.

type ObjInfo struct {
   Type int
   Value interface{}
}

Type - тип объекта может принимать одно и следующих значений:

  • ObjContract - контракт,
  • ObjFunc - функция,
  • ObjExtFunc - внешняя golang функция,
  • ObjVar - переменная,
  • ObjExtend - переменная $name.

Value - содержит соответствующую структуру для каждого типа.

type ContractInfo struct {
    ID uint32
    Name string
    Owner *OwnerInfo
    Used map[string]bool
    Tx *[]*FieldInfo
    Settings map[string]interface{}
}
  • ID - идентификатор контракта. Это значение указывается в блокчейне для вызове контракта.
  • Name - имя контракта.
  • Owner - дополнительная информация о контракте.
  • Used - map имен контрактов, которые вызываются внутри.
  • Tx - массив данных, которые описаны в разделе data у контракта.
type FieldInfo struct {
       Name string
      Type reflect.Type
      Tags string
}

где Name - имя поля, Type - тип, Tags - дополнительные теги для поля.

Settings - map значений, которые описываются в разделе settings у контракта.

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

Для типа ObjFunc поле Value содержит структуру FuncInfo

type FuncInfo struct {
     Params []reflect.Type
     Results []reflect.Type
    Names *map[string]FuncName
    Variadic bool
    ID uint32
}
  • Params - массив типов параметров,
  • Results - массив возвращаемых типов,
  • Names - map данных для tail функций. Например, DBFind().Columns().
type FuncName struct {
   Params []reflect.Type
   Offset []int
   Variadic bool
}
  • Params - массив типов параметров
  • Offset - массив смещений для этих переменных. По сути, все параметры, которые передаются в функциях через точку, являются переменными, которым могут быть присвоены инициализирующие значения.
  • Variadic - true, если tail описание может иметь переменной количество параметров.
  • Variadic - true, если у функции может быть переменной число параметров.
  • ID - идентификатор функции.

Для типа ObjExtFunc поле Value содержит структуру ExtFuncInfo. Она описывает функции на golang.

type ExtFuncInfo struct {
   Name string
   Params []reflect.Type
   Results []reflect.Type
   Auto []string
   Variadic bool
   Func interface{}
}

Совпадающие параметры как у структуры FuncInfo. Auto - массив переменных, которые дополнительно передаются в golang функций, если они есть. Например, переменные sc типа SmartContract, Func - golang функция.

Для типа ObjVar поле Value содержит структуру VarInfo

type VarInfo struct {
   Obj *ObjInfo
   Owner *Block
}
  • ObjInfo - информация о типе и значении переменной.
  • Owner - указатель на блок-хозяина.

Для объектов типа ObjExtend поле Value содержит строку с именем переменной или функции.

Команды виртуальной машины

Идентификаторы команд виртуальной машины описаны в файле cmds_list.go. Байт-код представляет из себя последовательность структур типа ByteCode.

type ByteCode struct {
   Cmd uint16
   Value interface{}
}

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

  • cmdPush - поместить значение из поля Value в стек. Например, используется для помещения в стек чисел, строк.
  • cmdVar - поместить значение переменной в стек. Value содержит указатель на структуру VarInfo c информацией о переменной.
  • cmdExtend - поместить в стек значение внешней переменной, они начинаются с $Value содержит строку с именем переменной.
  • cmdCallExtend - вызвать внешнюю функцию, их имена начинаются с $. Из стека будут взяты параметры функции, а результат(ы) функции будут помещены в стек. Value содержит имя функции.
  • cmdPushStr - поместить строку из Value в стек
  • cmdCall - вызвать функцию виртуальной машины. Value содержит указать на структуру ObjInfo. Эта команда применима как для ObjExtFunc golang функций, так и для ObjFunc Simvolio функций. При вызове функции передаваемые параметры берутся из стека, а результирующие значения возвращаются в стек.
  • cmdCallVari - аналогично команде cmdCall вызывает функцию виртуальной машины, но эта команда применяется для вызова функций с переменным числом параметров.
  • cmdReturn - служит для выхода из функции. При этом возвращаемые значения помещаются в стек. Value не используется.
  • cmdIf - передает управление байткоду в структуре Block, указатель на который передан в поле Value. Управление передается только, если вызов функции valueToBool c крайним элементом стека возвращает true.  В противном случае, управление передается следующей команде.
  • cmdElse - команда работает аналогично команде cmdIf, но управление указанному блоку передается только, если valueToBool c крайним элементом стека возвращает false.
  • cmdAssignVar - получаем из Value список переменных типа VarInfo, которым будет присваиваться значение с помощью команды cmdAssign.
  • cmdAssign - присвоить переменным полученным командой cmdAssignVar значения из стека.
  • cmdLabel - определяет метку, куда будет возвращаться управление в цикле while.
  • cmdContinue - команда передает управление на метку cmdLabel. Осуществляет новую итерацию цикла. Value не используется.
  • cmdWhile - проверяет крайний элемент стека с помощью valueToBool и вызывает Block передаваемые в поле Value, если значение true.
  • cmdBreak - осуществляет выход из цикла.
  • cmdIndex - получение в стек значения map или array по индексу. Value не используется.  (map|array) (index value) => ( map|array[index value] )
  • cmdSetIndex - присвоить элементу map или array крайнее значение стека. Value не используется. (map|array) (index value) (value) => (map|array)
  • cmdFuncName - добавляет параметры , которые передаются с помощью  последовательных описаний через точку  func name Func(…).Name(…).
  • cmdError - команда создается прекращает работу контракта или функции с ошибкой, которая была указана в error, warning или info.

Ниже идет команды непосредственно для работы со стеком. Поле Value в них не используется. Следует заметить, что сейчас нет полностью автоматического приведения типов. Например, string + float|int|decimal => float|int|decimal, float + int|str => float,  но int + string => runtime error.

  • cmdNot - логическое отрицание (val) => (!valueToBool(val))
  • cmdSign - смена знака. (val) => (-val)
  • cmdAdd -  сложение. (val1)(val2) => (val1+val2)
  • cmdSub - вычитание. (val1)(val2) => (val1-val2)
  • cmdMul - умножение.  (val1)(val2) => (val1*val2)
  • cmdDiv - деление. (val1)(val2) => (val1/val2)
  • cmdAnd - логическое И.  (val1)(val2) => (valueToBool(val1) && valueToBool(val2))
  • cmdOr - логическое ИЛИ.  (val1)(val2) => (valueToBool(val1) || valueToBool(val2))
  • cmdEqual - сравнение на равенство, возвращается bool. (val1)(val2) => (val1 == val2)
  • cmdNotEq - сравнение на неравенство, возвращается bool. (val1)(val2) => (val1 != val2)
  • cmdLess - сравнение на меньше, возвращается bool. (val1)(val2) => (val1 < val2)
  • cmdNotLess -  сравнение на больше или равно, возвращается bool. (val1)(val2) => (val1 >= val2)
  • cmdGreat - сравнение на больше, возвращается bool. (val1)(val2) => (val1 > val2)
  • cmdNotGreat  -  сравнение на меньше или равно, возвращается bool. (val1)(val2) => (val1 <= val2)

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

type RunTime struct {
   stack []interface{}
   blocks []*blockStack
   vars []interface{}
   extend *map[string]interface{}
   vm *VM
   cost int64
   err error
}
  • stack - стек, на которым происходит выполнение байт-кода,
  • blocks - стек вызовов блоков.
type blockStack struct {
     Block *Block
     Offset int
}
  • Block - указатель на выполняемый блок.
  • Offset - смещение последней выполняемой команды в байт-коде указанного блока.
  • vars - стек значений переменных. При вызове байт-кода в блоке, его переменные добавляются в этот стек переменных.

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

  • extend - указатель на map со значениями внешних переменных ($name).
  • vm - указатель на виртуальную машину.
  • cost - результирующая стоимость выполнения.
  • err - ошибка выполнения, если она была.

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

rt.blocks = append(rt.blocks, &blockStack{block, len(rt.vars)})

Далее мы получаем информацию о параметрах «хвостовых» функциях, которые должны находится в последнем элементе стека.

var namemap map[string][]interface{}
if block.Type == ObjFunc && block.Info.(*FuncInfo).Names != nil {
    if rt.stack[len(rt.stack)-1] != nil {
        namemap = rt.stack[len(rt.stack)-1].(map[string][]interface{})
    }
    rt.stack = rt.stack[:len(rt.stack)-1]
}

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

start := len(rt.stack)
varoff := len(rt.vars)
for vkey, vpar := range block.Vars {
   rt.cost--
   var value interface{}

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

if block.Type == ObjFunc && vkey < len(block.Info.(*FuncInfo).Params) {
    value = rt.stack[start-len(block.Info.(*FuncInfo).Params)+vkey]
} else {

Здесь мы инициализируем локальные переменные начальными значениями.

     value = reflect.New(vpar).Elem().Interface()
     if vpar == reflect.TypeOf(map[string]interface{}{}) {
        value = make(map[string]interface{})
     } else if vpar == reflect.TypeOf([]interface{}{}) {
        value = make([]interface{}, 0, len(rt.vars)+1)
     }
  }
  rt.vars = append(rt.vars, value)
}

Далее нам необходимо обновить значения у параметров-переменных, которые были переданы в «хвостовых» функциях.

if namemap != nil {
  for key, item := range namemap {
    params := (*block.Info.(*FuncInfo).Names)[key]
    for i, value := range item {
       if params.Variadic && i >= len(params.Params)-1 {

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

              off := varoff + params.Offset[len(params.Params)-1]
              rt.vars[off] = append(rt.vars[off].([]interface{}), value)
          } else {
              rt.vars[varoff+params.Offset[i]] = value
        }
     }
   }
}

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

if block.Type == ObjFunc {
     start -= len(block.Info.(*FuncInfo).Params)
}

После окончания работы цикла по выполнению команд байт-кода мы должны корректно очистить стек.

last := rt.blocks[len(rt.blocks)-1]

убираем из стека блоков текущий блок

rt.blocks = rt.blocks[:len(rt.blocks)-1]
if status == statusReturn {

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

  if last.Block.Type == ObjFunc {
    for count := len(last.Block.Info.(*FuncInfo).Results); count > 0; count-- {
      rt.stack[start] = rt.stack[len(rt.stack)-count]
start++
    }
  status = statusNormal
} else {

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

    return
  }
}
rt.stack = rt.stack[:start]

Рассмотрим другие функции для работы с виртуальной машиной. Любая виртуальная машина создается с помощью функции NewVM. В каждую виртуальную машину сразу добавляются три функции ExecContractCallContract и Settings. Добавление происхходит с помощью функции Extend.

for key, item := range ext.Objects {
    fobj := reflect.ValueOf(item).Type()

Мы перебираем все передаваемые объекты и смотрим только функции.

switch fobj.Kind() {
case reflect.Func:

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

data := ExtFuncInfo{key, make([]reflect.Type, fobj.NumIn()), make([]reflect.Type, fobj.NumOut()),
   make([]string, fobj.NumIn()), fobj.IsVariadic(), item}
for i := 0; i < fobj.NumIn(); i++ {

У нас есть так называемые Auto параметры. Как правило, это первый параметр, например sc SmartContract или rt Runtime. Мы не можем передавать их из языка Simvolio, но они нам необходимы при выполнении некоторых golang функций. Поэтому мы указываем какие переменные будут автоматически подставляться в момент вызова функции. В данном случае,  функции ExecContractCallContract имеют такой параметр rt Runtime.

if isauto, ok := ext.AutoPars[fobj.In(i).String()]; ok {
  data.Auto[i] = isauto
}

Заполняем информацию о параметрах

  data.Params[i] = fobj.In(i)
}

и о типах возвращаемых значений

for i := 0; i < fobj.NumOut(); i++ {
   data.Results[i] = fobj.Out(i)
}

Добавление функции в корневой Objects позволят компилятору в дальнейшем находить их при использовании из контрактов.

         vm.Objects[key] = &ObjInfo{ObjExtFunc, data}
    }
}

Компиляция

За компиляцию массива лексем, полученных  от лексического анализатора, отвечают функции, расположенные в файле compile.go. Компиляцию условно можно разделить на два уровня. На верхнем уровне мы обрабатываем функции, контракты, блоки кода, условные операторы и операторы цикла, определение переменных и т.д. На нижнем уровне, мы компилируем выражения, которые находятся внутри блоков кода или условий в цикле и условном операторе. В начале рассмотрим более простой нижний уровень.

Перевод выражений в байт код осуществляется в функции compileEval. Так как у нас виртуальная машина работает со стеком, то необходимо переводить обычную инфиксную запись выражений в постфиксную нотацию или обратную польскую запись. Например, 1+2 должно быть преобразовано в 12+, тоо есть вы помещаем 1 и 2 в стек, а затем применяем операцию сложения для двух последних элементов в стеке и записываем результат в стек. Сам алгоритм перевода можно найти в Интернете - например https://master.virmandy.net/perevod-iz-infiksnoy-notatsii-v-postfiksnuyu-obratnaya-polskaya-zapis/.  В глобальной переменной opers = map[uint32]operPrior содержатся приоритеты операций, которые необходимы при переводе в обратную польскую нотацию. В начале функции определяются следующие переменные:

  • buffer - временный буфер для команд байт-кода,
  • bytecode - итоговый буфер команд байт-кода,
  • parcount - временный буфер для подсчета параметров при вызове функций,
  • setIndex - переменная в процессе работы устанавливается в true, когда у нас происходит присваивание элементу map или array. Например, a[«my»] = 10. В этом случае, нам нужно будет использовать специальную команду cmdSetIndex.

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

case isRCurly, isLCurly:
     i--
    break main
case lexNewLine:
      if i > 0 && ((*lexems)[i-1].Type == isComma || (*lexems)[i-1].Type == lexOper) {
           continue main
      }
     for k := len(buffer) - 1; k >= 0; k-- {
          if buffer[k].Cmd == cmdSys {
              continue main
         }
     }
    break main

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

objInfo, tobj := vm.findObj(lexem.Value.(string), block)
if objInfo == nil && (!vm.Extern || i > *ind || i >= len(*lexems)-2 || (*lexems)[i+1].Type != isLPar) {
      return fmt.Errorf(`unknown identifier %s`, lexem.Value.(string))
}

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

if objInfo.Type == ObjContract {
    objInfo, tobj = vm.findObj(`ExecContract`, block)
    isContract = true
}

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

count := 0
if (*lexems)[i+2].Type != isRPar {
    count++
}

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

if isContract {
   name := StateName((*block)[0].Info.(uint32), lexem.Value.(string))
   for j := len(*block) - 1; j >= 0; j-- {
      topblock := (*block)[j]
      if topblock.Type == ObjContract {
            if topblock.Info.(*ContractInfo).Used == nil {
                 topblock.Info.(*ContractInfo).Used = make(map[string]bool)
            }
           topblock.Info.(*ContractInfo).Used[name] = true
       }
    }
    bytecode = append(bytecode, &ByteCode{cmdPush, name})
    if count == 0 {
       count = 2
       bytecode = append(bytecode, &ByteCode{cmdPush, ""})
       bytecode = append(bytecode, &ByteCode{cmdPush, ""})
     }
    count++

}

Если мы видим что далее идет квадратная скобка, то мы добавляем команду cmdIndex для получения значения по индексу.

if (*lexems)[i+1].Type == isLBrack {
     if objInfo == nil || objInfo.Type != ObjVar {
         return fmt.Errorf(`unknown variable %s`, lexem.Value.(string))
     }
    buffer = append(buffer, &ByteCode{cmdIndex, 0})
}

Если функция compileEval непосредственно формирует байт-код выражений в блоках, то функция CompileBlock формирует как дерево объектов, так и байт-код не относящийся к выражениям. Компиляция также основана на работе конечного автомата, подобно тому как это было сделано для лексического анализа, но со следующими отличиями. Во-первых, мы оперируем уже не символами, а лексемами, а во-вторых, мы все состояния и переходы сразу описываем в переменной states. Она представляет собой массив map c индексами по типам лексем и для каждой лексемы указывается структура compileState с новым состоянием в поле NewState и, если понятно  какую конструкцию мы разобрали, то указывается функция обработчик в поле Func.

Рассмотрим, например, главное состояние

{ // stateRoot
   lexNewLine: {stateRoot, 0},
   lexKeyword | (keyContract << 8): {stateContract | statePush, 0},
   lexKeyword | (keyFunc << 8): {stateFunc | statePush, 0},
   lexComment: {stateRoot, 0},
   0: {errUnknownCmd, cfError},
},

Если мы встречаем перевод строки или комментарии, то остаемся на этом же состоянии. Если встречаем ключевое слово contract, то переходим в состояние stateContract и начинаем разбор этой конструкции. Если встречаем ключевое слово func, то переходим в состояние stateFunc. В случае получения других лексем будет вызвана функция генерации ошибки. Предположим, что у нас встретилось ключевое слово func и мы перешли в состояние stateFunc.

{ // stateFunc
    lexNewLine: {stateFunc, 0},
    lexIdent: {stateFParams, cfNameBlock},
    0: {errMustName, cfError},
},

Так как после ключевого слова func должно идти имя функции, то при переводе строки мы остаемся в этом же состоянии, а при всех других лексемах мы генерируем соответствующую ошибку. Если мы получили имя функции в лексеме-идентификаторе, то мы переходим в состояние stateFParams в котором мы получим параметры функции. При этом мы вызываем функцию fNameBlock. Следует заметить, что структура типа Block была создана по флагу statePush и здесь мы берем её из буфера и заполняем нужными нам данными.

func fNameBlock(buf *[]*Block, state int, lexem *Lexem) error {
    var itype int

    prev := (*buf)[len(*buf)-2]
    fblock := (*buf)[len(*buf)-1]
   name := lexem.Value.(string)
   switch state {
     case stateBlock:
        itype = ObjContract
       name = StateName((*buf)[0].Info.(uint32), name)
       fblock.Info = &ContractInfo{ID: uint32(len(prev.Children) - 1), Name: name,
           Owner: (*buf)[0].Owner}
    default:
       itype = ObjFunc
       fblock.Info = &FuncInfo{}
     }
     fblock.Type = itype
    prev.Objects[name] = &ObjInfo{Type: itype, Value: fblock}
    return nil
}

Функция fNameBlock используется для контрактов и функции (в том числе вложенных в другие функции и контракты). Она заполняет поле Info соответствующей структурой и заносит себя в map Objects родительского блока. Это чтобы затем мы могли вызывать данную функцию или контракт по данному имени. Подобным образом мы создаем функции для всех состояний и вариантов, эти функции как правило очень небольшие и выполняют определенную работу по формированию дерева виртуальной машины. Что касается функции CompileBlock, то она просто проходит по всем лексемам и переключает состояния в соответствии с состояниями описанными в states. Почти весь дополнительный код обработки дополнительных флагов.

  • statePush - происходит добавление объекта Block в дерево объектов.
  • statePop - используется при окончании блока на закрывающих фигурных скобках.
  • stateStay - указывает на то, что при переходе в новое состояние нужно остаться на текущей лексеме.
  • stateToBlock - указывает на переход в состояние stateBlock. Используется для обработки while и if, когда необходимо после обработки выражения перейти в обработку блока внутри фигурных скобок.
  • stateToBody - указывает на переход в состояние stateBody.
  • stateFork - сохраняет позицию лексемы. Используется когда выражение начинается в идентификаторе или имени с $. У нас может быть или вызов функции или присваивание.
  • stateToFork - используется для получения лексемы сохраненной по флагу stateFork. Эта лексема будет передаваться в функцию обработчик.
  • stateLabel - служит для вставки команды cmdLabel. Этот флаг нужен для конструкции while.
  • stateMustEval - проверяет наличие условного выражения в начале конструкций if и while.

Кроме функции CompileBlock следует упомянуть ещё функцию FlushBlock. Дело в том, что при компиляции строится дерево блоков независимо от существующей виртуальной машины. Точнее, мы берем информацию о существующих функциях и контрактах в виртуальной машине, но откомпилированные блоки собираем в отдельное дерево. В противном случае, в случае возникновения ошибки при компиляции, мы обязаны будем откатить состояние виртуальной машины к предыдущему состоянию. Поэтому мы собираем дерево отдельно, но должны вызвать функцию FlushContract после успешного окончания компиляции. Эта функция добавляет наше готовое дерево блоков в текущую виртуальную машину. На этом этап компиляции считается законченным.

Лексический анализ

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

  • sys - системная лексема. например: {}[](),.
  • oper - оператор +-/*
  • number - число,
  • ident - идентификатор,
  • newline - перевод строки,
  • string - строка,
  • comment - комментарий.

В данной версии предварительно с помощью script/lextable/lextable.go строится таблица переходов (конечный автомат) для разбора лексем, которая записывается в файл lex_table.go. В принципе, можно избавиться от предварительной генерации этого файла и создавать таблицу переходов сразу в памяти при запуске (в init()). Сам лексический анализ происходит в функции lexParser в lex.go.

lextable/lextable.go

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

states содержит JSON объект содержащий список состояний.

Кроме конкретных символов, за d обозначены все символы, которые не указаны в состоянии

n - 0x0a, s - пробел, q - обратные кавычки `, Q - двойные кавычки, r - символы >= 128 a - A-Z и a-z, 1 - 1-9

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

Например, у нас состояние main и входящий символ /. "/": ["solidus", "", "push next"],

push даёт команду запомнить его в отдельном стеке, а next - перейти к следующему символу, при этом мы меняем состояние на solidus. После этого, берем следующий символ и смотрим на состояние solidus.

Если у нас / или * - то мы переходим в состояние комментарий, так они начинаются с // или /*. При этом видно, что для каждого комментария разные последующие состояния, так как заканчиваются они разными символами.

А если у нас следующий символ не / и не *, то мы все что у нас положено в стек (/) записываем как лексему с типом oper, очищаем стэк и возвращаемся в состояние main.

Данный модуль переводит данное дерево состояний в числовой массив и записывает его в файл lex_table.go.

В первом цикле

for ind, ch := range alphabet {
i := byte(ind)

мы формируем алфавит допустимых символов. Далее в state2int мы каждому состоянию даем свой  порядковый идентификатор.

state2int := map[string]uint{`main`: 0}
if err := json.Unmarshal([]byte(states), &data); err == nil {
for key := range data {
if key != `main` {
state2int[key] = uint(len(state2int))

Далее проходимся по всем состояниям и для каждого множества в состоянии и для каждого символа в этом множестве мы записываем в двумерный массив table трех-байтное число [id нового состояния (0=main)] + [тип лексемы (0-нет лексемы)] + [флаги]. Двухмерность массива table заключена в том, что разбит на состояния и 33 входящих символа из массива alphabet расположенных в таком же порядке. То есть, в дальнейшем мы будем работать с этой таблице примерно следующим образом.

Находимся в состоянии main на нулевой строке таблицы table. Берем первый символ, смотрим его индекс в массиве alphabet и берем значение из столбца с данным индексом. Далее из полученного значения в младшем байте получаем флаги, во втором байте - тип полученной лексемы, если её разбор закончен, и в третьем байте получаем индекс нового состояния, куда нам следует перейти. Всё это подробнее будет рассмотрено в функции lexParser в файле lex.go.

Если нужно добавить какие-то новые символы, то нужно добавить их в массив alphabet и увеличить константу AlphaSize. Если нужно добавить новую комбинацию символов, то их следует описать в states, аналогично существующим вариантам. После этого следует, запустить lextable.go, чтобы обновился файл lex_table.go.

lex.go

Функция lexParser непосредственно производит лексический анализ и на основе входящей строки возвращает массив полученных лексем. Рассмотрим структуру лексемы

type Lexem struct {
   Type uint32 // Type of the lexem
   Value interface{} // Value of lexem
   Line uint32 // Line of the lexem
   Column uint32 // Position inside the line
}
  • Type - тип лексемы. Может быть одним из следующих значений: lexSys, lexOper, lexNumber, lexIdent, lexString, lexComment, lexKeyword, lexType, lexExtend,
  • Value - значение лексемы. Тип значения зависит о типа. Рассмотрим подробнее,
  • lexSys - сюда относятся скобки, запятые и т.п. В этом случае, Type = ch<<8 | lexSys - смотрите константы isLPar … isRBrack, а само Value равно uint32(ch),
  • lexOper - значения представляют из себя эквивалентную последовательность символов в виде uint32. Например, смотрите константы isNot…isOr,
  • lexNumber - числа хранятся в виде int64 или float64. Если у числа указана десятичная точка, то это float64.
  • lexIdent - идентификаторы хранятся в виде строк,
  • lexNewLine - символ перевода строки. Также служит для подсчета строки и позиции лексемы.
  • lexString - строки хранятся в виде строки string,
  • lexComment - комментарии также хранятся в виде строк string,
  • lexKeyword - ключевые слова хранят только соответствующий индекс - константы от keyContract…keyTail. В этом случае, Type = KeyID << 8 | lexKeyword. Также, следует заметить, что ключевые слова true,false,nil сразу преобразуются в лексемы типа lexNumber, с соответствующими типами bool и intreface{},
  • lexType - в этом случае, значение содержит соответствующее значение типа reflect.Type,
  • lexExtend - это идентификаторы, начинающиеся со знака доллара $. Эти переменные и функции передаются извне и поэтому выделяются в специальный тип лексем. Значение содержит имя в виде строки без начального знака доллара.
  • Line - строка, где обнаружена лексема.
  • Column - Позиция лексемы в строке.

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

Осталось рассмотреть флаги, которые используются при разборе:

  • push - этот флаг означает, что начинаем накапливать символы в новую лексему,
  • next - символ необходимо добавить к текущей лексеме,
  • pop - получение лексемы закончено. Как правило, с этим флагом у нас выдается идентификатор-тип разобранной лексемы,
  • skip - этот флаг используется для исключения символа из разбора. Например, управляющие слэш символы в строке - \n \r ". Они автоматически заменяются на этапе этого лексического анализа.

Язык Simvolio

Лексемы

Исходный текст программы должен быть в кодировке UTF-8. Обрабатываются следущие типы лексем:

  • Ключевые слова - action break conditions continue contract data else error false func if info nil return settings true var warning while
  • Числа - принимаются только числа в десятичной системе исчисления. Других видов записи чисел на данный момент нет. Имеется два базовых типа - int и float. При наличии десятичной точки число становится типом float. int соотвествует типу int64 (в golang), а тип float типу float64 (в golang).
  • Строки - строка может заключаться в двойные («строка») или обратные кавычки (строка). Строки обоих типов могут включать в себя переносы строк. Кроме этого, строка в двойных кавычках может содержать в себе двойные кавычки, перенос строки и возврат картеки, определенные с помощью слэша (\). Например, «Это "первая строка".\r\nЭто вторая строка.».
  • Комментарии - имеется два вида комментариев. Комментарий до конца строки нчинается с двойного слэша. Например, // это комментарирй до конца строки. Комментарий между слешами со звездочками может быть на несоклько строк. Например, /* Этот комментарий может быть на несколько строк */.
  • Идентификаторы - имена переменных и функций, которые состоят из латинских букв, UTF-8 символов, цифр и знака подчеркивания. Они могут начинаться с буквы, подчёркивания, @ и $. Со знака доллара начинается обращение к переменным-параметрам, которые определены в секции data. Также с помщью доллара можно определять глобальные переменные, в общей области видимости функций conditions и action. Со знака амперсенд, можно вызывать контракт с указанием экосистемы. Например, @1NewTable(…).

Типы

Рядом с типами указаны соответствующие типы из golang.

  • bool - bool, значение по умолчанию false,
  • bytes - []byte{}, по умолчанию присваивается пустой массив байт.
  • int - int64, значение по умолчанию 0,
  • address - uint64, значение по умолчанию 0,
  • array - []interface{}, по умолчанию создается пустой массив,
  • map - map[string]interface{}, по умолчанию создается пустой ассоциативный массив,
  • money - decimal.Decimal, значение по умолчанию 0,
  • float - float64, значение по умолчанию 0,
  • string - string, по умолчанию создается пустая строка.

В можете описать переменные данных типов с помощью ключевого слова var. Например, var var1, var2 int. При этом, переменным сразу присвоится значение по умолчанию, которое определено для данного типа.

Выражения

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

  • Самый большой приоритет операция у вызовов функций и круглых скобок. При вызове функций, вначале слева направо вычисляются передаваемые параметры.
  • Унарные операции - логическое отрицание ! и арифметическая смена знака -.
  • Умножение и деление - арифметические умножение и деление * и /.
  • Сложение и вычитание - арифметические сложение и вычитание - и +.
  • Логические сравнения - >= > > >=.
  • Логические равенства и неравенства - == !=.
  • Логическое И - &&.
  • Логическое ИЛИ - ||.

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

В языке сейчас отсутствует проверка типов на этапе компиляции, но при вычислении операндов с разными типами делается попытка привести тип операндов к более сложному типу. Типы в порядке возрастная сложности можно расположить следующим образом - string, int, float, money. К сожалению, сейчас реализованы не все комбинации операндов разных типов. Для строк сущестувует операция сложения, которая означает конкатенацию строк.

Например, string + string = string, money - int = money, int * float = float

Что касается вызовов функций, то там проверка типов осуществляется только в момент выполнения и проверяются только типы string и int.

Для типов array и map существует обращение по индексу []. В случае array в качестве индекса должно указываться значение тип int, а при обращении к элементу map необходимо указывать переменную или значение типа string. Если происходит присваивание элементу array c индексом больше чем размер массива, то в данный массив автоматически будут добавлены недостающие элементы, которые будут инициализированы значениями nil. Например,

var my array
my[5] = 0
var mymap map
mymap["index"] = my[3]

В выражениях, где необходимо логическое значение, таких как if, while, &&, ||, ! существует автоматическое преобразование типов к логическому значению.

  • bytes - true, если размер больше 0,
  • int - true, если не 0,
  • array - true, если не nil и размер больше 0,
  • map - true, если не nil и размер больше 0,
  • money - true, если не 0,
  • float - true, если не 0,
  • string - true, если размер больше 0,
var mymap map
var val string
if mymap && val {
...
}

Область видимости

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

var a int
a = 3
{
   var a int
   a = 4
   Println(a) // 4
}
Println(a) // 3

Выполнение контрактов

При вызове контракта ему должны передаваться параметры описанные в секции data. Перед выполнением контракта виртуальная машина получает эти парамтры и присваивает их соответствующим переменным ($Param). После этого, вызывается предопределенная функция conditions и после неё функция action. Если в контракте определена функция rollback, то она будет вызываться при откате данного контракта.

Ошибки, которые возникают в момент выполнения контракта можно разделить на два типа - генерируемые ошибки и ошибки среды выполнения. Ошибки первого типа генерируются с помощью специальных команд error, warning, info, а также при выполнении встроенных функций, когда они возвращает err не равный nil. Ошибки среды выполнения могут возникать в результате некорректных данных, которые преварительно не были проверены. Например, выход за границы массива, несовпадение типов при вызове функций и т.д. Следует заметить, что все значения переменных изначально имеют тип interface{} и затем им присваивается значение соответствующего golang типа. Так, например, типы array и map это соответственно golang типы []interface{} и map[string]interface{}. Оба типа массивов могут содержать элементы любых типов. Ещё, если мы присваиваем сразу myarr[2] = 0, то у нас автоматически вставляется 0 и 1 элемент со значением nil. В этом случае при использовании элемента myarr[0] в каком-нибудь арифметическом выражении мы получим ошибку виртуальной машины. Обработка исключений в языке отсутствует, то есть любая ошибка завершает выполнение контракта. Так как при выполнении каждого контракта создается свой стэк и структуры для хранения значений переменных, то после завершения работы контракта эти данные будут автоматически удалены сборщиком мусора golang.

БНФ

<десятичная цифра> ::= „0“ | „1“ | „2“ | „3“ | „4“ | „5“ | „6“ | „7“ | „8“ | „9“

<десятичное число> ::= <десятичная цифра> {<десятичная цифра>}

<код символа> ::= „“„<любой символ>“„“

<действительное число> ::= [„-„] <десятичное число>“.“[<десятичное число>]

<целое число> ::= [„-„] <десятичное число> | <код символа>

<число> := <целое число> | <действительное число>

<буква> ::= „A“ | „B“ | … | „Z“ | „a“ | „b“ | … | „z“ | 0x80 | 0x81 | … | 0xFF

<пробел> ::= 0x20

<табуляция> ::= 0x09

<конец строки> := 0x0D 0x0A

<спецсимвол> ::= „!“ | „»“ | „$“ | „““ | „(„ | „)“ | „*“ | „+“ | „,“ | „-„ | „.“ | „/“ | „<“ | „=“ | „>“ | „[„ | „“ | „]“ | „_“ | „|“ | „}“ | „{„ | <табуляция> | <пробел> | <конец строки>

<символ> ::= <десятичная цифра> | <буква> | <спецсимвол>

<имя> ::= (<буква> | „_“) {<буква> | „_“ | <десятичная цифра>}

<имя функции> ::= <имя>

<имя переменной> ::= <имя>

<имя типа> ::= <имя>

<стр символ> ::= <табуляция> | <пробел> | „!“ | „#“ | … | „[„ | „]“ | …

<элемент строки> ::= {<стр символ> | „»“ | „n“ | „r“ }

<строка> ::= „»“ { <элемент строки> } „»“ | „`“  { <элемент строки> } „`“

<оператор присваивания> ::= „=“

<оператор унарный> ::= „-„

<оператор бинарный> ::= „==“ | „!=“ | „>“ | „<“ | „<=“ | „>=“ | „&&“ | „||“ | „*“ | „/“ | „+“ | „-„

<оператор> ::=  <оператор присваивания> | <оператор унарный> | <оператор бинарный>

<параметры> ::= <выражение> {„,“<выражение>}

<вызов контракта> ::= <имя контракта> „(„ [<параметры>] „)“

<вызов функции> ::= <вызов контракта> [{„.“ <имя> „(„ [<параметры>] „)“}]

<содержимое блока> ::= <команда блока> {<конец строки><команда блока>}

<блок> ::= „{„<содержимое блока>“}“

<команда блока> ::= (<блок> | <выражение> | <определение переменных> | <if> | <while> | break | continue | return)

<if> ::= if <выражение><блок> [else <блок>]

<while> ::= while <выражение><блок>

<contract> ::= contract <имя> „{„[<секция data>] {<функция>} [<conditions>] [<action>]“}“

<секция data> ::= data „{„ {<параметр data><конец строки>} „}“

<параметр data> ::= <имя переменной> <имя типа> „»“{<tag>}“»“

<tag> ::= optional | image | file | hidden | text | polymap | map | address | signature:<имя>

<conditions> ::= conditions <блок>

<action> ::= action <блок>

<функция> ::= func <имя функции>“(„[<описание переменной>{„,“<описание переменной>}]“)“[{<tail>}] [<имя типа>] <блок>

<описание переменной> ::= <имя переменной> {„,“ <имя переменной>} <имя типа>

<tail> ::= „.“<имя функции>“(„[<описание переменной>{„,“<описание переменной>}]“)“

<определение переменных> ::= var <описание переменной>{„,“<описание переменной>}