Давайте добавим в Go условное выражение
- суббота, 21 марта 2026 г. в 00:00:57
Если вы являетесь Go-разработчиком, то вне зависимости от того, из какого языка программирования пришли в Go, наверняка когда-то задавались вопросами «А есть ли тут тернарный оператор? Нет? А почему?»
Конечно, можно заглянуть в секцию FAQ документации Go и найти там ответ авторов. Но останавливаться на этом — удел слабых, так?) Иногда ведь так хочется удобно написать присвоение результата в зависимости от условия... Без заведения лишних временных переменных, и может быть даже в одну строчку...
А может быть, мы и сами в состоянии изменить язык Go, чтобы поддержать в нём условное выражение? Давайте-ка попробуем погрузиться в недра его компилятора и реализовать наше желание.
⚠️ Хочу сразу обозначить, что этой статьей я не намереваюсь разжечь споры о дизайне языка Go, желании добавить в него функциональщины и вообще что-то в нём поменять. Как бы то ни было, последнее слово в этих вопросах всё равно остаётся за авторами языка, да и как показывала практика с теми же generic методами структур и с generic-ами в целом, это слово может меняться. Как максимум, мы можем сделать только proposal фичи в язык, а в среднем — только пофантазировать, как она в нашем розовом мире могла бы смотреться. Не спорить же нам только из-за своих фантазий :) А целью этой статьи является всё же погружение в устройство компилятора, чтобы попробовать реализовать что-то интересное
В императивных языках программирования, к которым относится Go, существует два вида конструкций — statements (инструкции) и expressions (выражения).
Для инструкций результатом исполнения является некоторый побочный эффект — например, вывод в окно терминала некоторого текста, объявление переменной или присвоение ей значения, ...
Результатом же вычисления выражения обязательно является некоторое значение — число 10 как результат арифметической операции 5+5, результат вызова функции с возвращаемым значением, ... И разработчик имеет полное право компоновать выражения, передавая результат вычисления одного из них другому — например, как вызове функции f(5+5).
Справедливости ради, в императивных языках в результате вычисления выражений тоже могут происходить побочные эффекты, но при этом и возвращаемое значение тоже имеется.

Как можно понять из сказанного выше, результатом вычисления условного выражения тоже является некоторое значение. Пусть, оно будет равно значению выражения T (от слова true), если условие C (от слова condition) истинно, и значению выражения F (от слова false, соответственно) — если оно ложно. Полученное после вычисления значение можно куда-то передать для дальнейшего использования. Чего нельзя сказать про привычный для Go if-statement — просто набор последовательных инструкций, которые выполнятся в зависимости от истинности условия, никакое значение как результат их исполнения использовать не получится.
Простенький пример можно посмотреть ниже — чтобы сделать достаточно простые операции, нужно написать несколько if-ветвлений. И вроде бы всё понятно, но если код разрастётся, то следить за ходом мысли станет существенно сложнее (особенно если значения начинают переиспользоваться, обновляться). Можно, конечно, создать несколько функций, но они получатся совсем простыми, немногие на это пойдут.
type Person struct { Category string Name string Age int } func NewPerson(name string, age int) *Person { p := &Person{Name: name, Age: age} if p.Name == "" { p.Name = "Unknown" } if p.Age < 18 { p.Category = "Child" } else { p.Category = "Adult" } return p }
С наличием условного выражения, например, с синтаксисом ?: тернарного оператора, код становится короче. И, что более важно, он перестаёт требовать возврата к строчкам выше, чтобы понять, что это за поле, изменялось ли оно, ... Не спорю, что у такого синтаксиса есть минусы, но в умеренной концентрации именно возможность написать такое выражение может упростить понимание и написание кода.
func NewPerson(name string, age int) *Person { return &Person{ Name: name == "" ? "Unknown" : name, Age: age, Category: age < 18 ? "Child" : "Adult", } }
Концепцию условного выражения освежили, теперь давайте чуть подробнее разберёмся, какие требования для его написания и исполнения предъявляются и как, собственно, его вычислять.
В языках со слабой типизацией, например, Python, типы выражений T и F совершенно не обязаны совпадать. Для выражения C из условия требуется приводимость к bool, чтобы определять исполняемую ветку выражения. С подобным подходом, тип всего условного выражения определяется выражением дальнейшей исполняемой ветки. При неудачном стечении дальнейших обстоятельств программа может крашнуться, но так писать можно.
s = "some string" condition = False # тип условного выражения либо int, либо tuple result = int(s) if condition else (0, 42)
Для компилируемых языков и для языков с более строгой типизацией часто корректно, что типы выражений T и F могут быть любыми, но обязательно одинаковыми (давайте обозначим этот тип как А), или, если нет, должны быть способы неявно привести один тип к другому. Зная это всё ещё на этапе компиляции, мы можем гарантировать, что тип условного выражения будет одинаковым при всех потоках исполнения, что сделает программу более безопасной.
Разумеется, требование к типу выражения C быть равным bool никуда не пропадает.

Для понимания работы условного выражения нужно уметь различать порядки вычисления выражений в языках программирования
strict evaluation — для вычисления значения выражения, все подвыражения обязательно должны быть предварительно вычислены. Например, в императивных языках программирования по типу Go, в выражении f(g(x), h(x)) перед вызовом функции f должны быть вычислены выражения g(x) и h(x). Более того, Go специфицирует порядок вычисления самих аргументов, но для нас здесь важно только то, что они вычисляются строго до начала вычисления функции f
non-strict evaluation — значение выражения может быть вычислено раньше, чем значения всех её аргументов. И, пожалуй, самыми простыми примерами такого порядка вычисления будут логические выражения — a() AND b() и c() OR d(). В первом случае очевидно, что если выражение a() имеет значение FALSE, то всё выражение ложно, оставшиеся подвыражения вычислять не нужно. Аналогично для второго случая — если подвыражение c() истинно, то и всё выражение истинно. Часто в языках программирования подвыражения логических выражений вычисляются только до того момента, пока ответ не будет однозначным, не производя излишних вычислений. Это чуть более частный случай нестрогого вычисления и относится он к short-circuited evaluation

На самом деле, если выражения не могут иметь побочных эффектов, то разницы в результатах работы между двумя вышеописанными подходами не будет (за исключением производительности). Но если побочные эффекты допустимы, то, говоря про условное выражение, исполняться должна только соответствующая значению C ветвь. С нашим желанием добавить условное выражение в язык Go именно этот ленивый подход звучит уместно.
Такое поведение может казаться интуитивным, но в одном из обсуждений условного выражения как предложения в язык Go, контрибьюторы упомянули, что вымышленная функция cond(c, t, f) будет единственной встроенной функцией в язык с ленивым вычислением.
Да, с теоретической точки зрения мы уже действительно почти готовы реализовать задуманную функциональность условного выражения в Go.
Отмечу, что я буду обновлять исходный код компилятора версии go1.25.1, а для сборки буду использовать go1.25.0. Ниже я привёл упрощённый пайплайн компиляции Go-кода, некоторые стадии которого нам потребуется обновить, чтобы поддержать реализацию условного выражения.

Давайте для начала зафиксируем сам синтаксис условного выражения, который мы хотим научиться парсить. Я его уже пытался преподносить по ходу статьи до этого момента и сейчас предлагаю чуть более формально его установить
Expression = UnaryExpr | Expression binary_op Expression . UnaryExpr = CondExpr | PrimaryExpr | unary_op UnaryExpr . ... CondExpr = "if" Expression "{" Expression "}" "else" "{" Expression "}" .
Да, здесь условное выражение — в каком-то смысле является унарным с оператором if :) Один из ранее рассмотренных примеров с таким синтаксисом смотрелся бы следующим образом.
package main import "fmt" type Person struct { Category string Name string Age int } func NewPerson(name string, age int) *Person { return &Person{ Name: if name == "" { "Unknown" } else { name }, Age: age, Category: if age < 18 { "Child" } else { "Adult" }, } } func main() { fmt.Println(NewPerson("Matvey", 21)) }
Я выбрал такой синтаксис по следующим причинам
Для Go он лично мне кажется органичным. Читается примерно так же, как и обычный if-statement. Более того, стандартные текстовые редакторы без изменений в них будут в состоянии подсветить ключевые слова и ветви. Хабр тоже справляется :)
Не требует добавления новых ключевых слов (их совсем несложно добавить, но зачем?)
Относительно несложно парсится на фоне тернарного оператора :?, который еще и читается с трудом на высоком уровне вложенности. Плюс, не знаю, как вы, а я, если вижу несколько подряд таких операторов, постоянно вспоминаю, слева или направо их вычислять надо. Да и вместе с другими логическими и арифметическими операторами каша получается
Как вы поняли, лексер (scanner, tokenizer, ... как угодно) нам менять не нужно, поэтому сразу ныряем во фронтенд компилятора, а именно в парсер, пакет src/cmd/compile/internal/syntax. Чтобы поддержать разбор условного выражения выражения, давайте в файл с исходным кодом парсера parser.go добавим функцию для парсинга именно тернарного условного выражения
func (p *parser) ternaryExpr() Expr { if trace { defer p.trace("ternaryExpr")() } // В парсере уже есть функция для чтения заголовка цикла или if, // давайте ей воспользуемся и просто запретим иметь init-выражение init, condExpr, _ := p.header(_If) if init != nil { p.syntaxError("ternary expression does not support variable initialization") } p.want(_Lbrace) thenExpr := p.expr() p.want(_Rbrace) p.want(_Else) p.want(_Lbrace) elseExpr := p.expr() p.want(_Rbrace) ternary := new(TernaryExpr) ternary.Cond = condExpr ternary.Then = thenExpr ternary.Else = elseExpr ternary.pos = condExpr.Pos() return ternary }
А вызывать её будем, соответственно, в том месте, где требуется парсинг унарного выражения при условии, что следующий токен — if
func (p *parser) unaryExpr() Expr { if trace { defer p.trace("unaryExpr")() } // Смотрим на следующий токен, но не размечаем его, как прочитанный switch p.tok { case _If: return p.ternaryExpr() case _Operator, _Star: // ... }
По большому счёту, это почти всё, что надо знать про парсинг условного выражения. Сама по себе структура для него достаточно несложная, за исключением одного нюанса, который пока непонятен и мы затронем его позднее по надобности.
type TernaryExpr struct { Cond, Then, Else Expr expr isNil bool } func (te *TernaryExpr) IsNil() bool { return te.isNil } func (te *TernaryExpr) SetNil() { te.isNil = true }
Ах да, в Go же отдельные инструменты по типу go vet, go test также требуют для работы парсеры и тайпчекеры! Но им подавай отдельную реализацию с публичным API, а не описанную выше внутреннюю, она нужна только внутрянке команды compile. В общем, надо реализовать примерно то же самое в пакетах src/go/parser и src/go/ast. С вашего позволения, тут я скажу, что это можно оставить читателю в качестве упражнения :)
Давайте попробуем собрать наш код на этом моменте и запустить. Для этого из директории src можно запустить скрипт
./make.bash — он прогонит все этапы сборки компилятора и в директорию bin сложит исполняемый файл компилятора go вместе с gofmt.
./all.bash, запускающий помимо сборки и всевозможные тесты компилятора. Хочу отметить, что они достаточно хорошо организованы, и в том числе требуют ведения changelog-ов, добавления туда номера GitHub Issue, которую закрывает новая фича и прочего.
Ну дак вот-с, давайте запустим тот пример, что мы придумали ранее. Предварительно нужно сместить GOROOT на нашу реализацию компилятора, чтобы компилятор пользовался новым SDK. На случай, если что-нибудь у нас развалится, то мы сможем увидеть трейс ошибки компилятора, указав ему отладочный флаг -h
$ GOROOT=/Users/m0t9/projects/custom-go $ ./go run -gcflags="-h" main.go # command-line-arguments <unknown line number>: internal compiler error: panic: ./main.go:13:23: unknown expression type *syntax.TernaryExpr Please file a bug report including a short program that triggers the error. https://go.dev/issue/new panic: ./main.go:13:23: unknown expression type *syntax.TernaryExpr [recovered, repanicked] panic: -h goroutine 1 [running]: cmd/compile/internal/base.hcrash() cmd/compile/internal/base/print.go:267 +0x58 cmd/compile/internal/base.FatalfAt({0x3b768?, 0x140?}, {0x100a7d47a, 0x9}, {0x1400003b798, 0x1, 0x1}) cmd/compile/internal/base/print.go:235 +0x244 cmd/compile/internal/base.Fatalf(...) cmd/compile/internal/base/print.go:195 cmd/compile/internal/gc.handlePanic() cmd/compile/internal/gc/main.go:54 +0x90 panic({0x100c55ac0?, 0x14000039ab0?}) runtime/panic.go:783 +0x120 cmd/compile/internal/types2.(*Checker).handleBailout(0x140003961e0, 0x1400003d118) cmd/compile/internal/types2/check.go:442 +0x248 panic({0x100c55ac0?, 0x14000039ab0?}) runtime/panic.go:783 +0x120 cmd/compile/internal/types2.(*Checker).exprInternal(0x140003961e0, 0x0, 0x1400043f6c0, {0x100d1b730, 0x14000145420}, {0x0?, 0x0?}) cmd/compile/internal/types2/expr.go:1303 +0x19a4 ...
Ага, types2.(*Checker)... Судя по отладочному выводу, пайплайн компилятора дошёл до тайпчекера. Значит, пора и в нём поддержать обработку условного выражения
Нам потребуется залезть в пакет тайпчекера cmd/compile/internal/types2, чтобы вывести тип тернарного выражения и убедиться, что в дочерних C, T и F выражениях тоже нет ошибок.
Небольшой набор пререквизитов при погружении в этот пакет
operand — промежуточная структура для хранения значения при проверке типов. В ней хранится выражение и его тип, режим адресации значения (константа, адресуемая переменная, без значения, ...) и для констант — собственно, само значение.
Checker.expr — метод тайпчекера, проверяющий и выводящий типы для одиночного выражения. В Go функции могут возвращать несколько значений. И это не структуры, не кортежи, это именно несколько отдельных друг от друга значений — multi-выражения. В моём представлении, которое во многом совпадает с описанным в этом посте, так делать не стоит в целом и в идеале под такое надо выделять кортежи. Поэтому условное выражение в моей реализации возвращает одно значение. Ну и сама функция для его проверки вызывается через expr
func (check *Checker) ternary(T *target, x *operand, tern *syntax.TernaryExpr) { // C — condition, T – then, e — else. Тут я чуть другие обозначения использовал var c, t, e operand // Выведем типы подвыражений для условного выражения check.expr(newTarget(Typ[Bool], "ternary's condition"), &c, tern.Cond) check.expr(T, &t, tern.Then) check.expr(T, &e, tern.Else) // Если где-то mode == invalid, значит есть ошибка типизации // Можем забить на дальнейшие шаги if c.mode == invalid || t.mode == invalid || e.mode == invalid { return } // Для различных строковых и числовых констант без типа хотим уметь находить // их тип по умолчанию. Небольшая helper-функция toTyped := func(op *operand) { if isUntyped(op.typ) { op.typ = Default(op.typ) } } // Ну и сразу же её применим toTyped(&c) toTyped(&t) toTyped(&e) // В условии тернарника не Bool? Ну это косяк, надо завершиться с ошибкой if !isBoolean(c.typ) { check.errorf(&c, MismatchedTypes, "type of the ternary's condition should be %s", "boolean") } // Если в какой-то из ветвей nil — давайте попробуем привести её тип к другой ветви. // Случай, если оба nil будем обрабатывать отдельно if t.isNil() && !e.isNil() { check.convertUntyped(&t, e.typ) } else { check.convertUntyped(&e, t.typ) } // Если по итогу оказалось, что конвертация выше провалилась, или типы // ветвей оказались разными — значит пора снова сказать об этом пользователю! if t.mode == invalid || e.mode == invalid || !Identical(t.typ, e.typ) { check.errorf(&t, MismatchedTypes, "types of then- and else- branches of ternary operator should be identical, got: %q and %q", t.typ, e.typ) } // На этом этапе у нас уже все типы валидные. Надо их записать в мапу // expression —> type, чтобы компилятор мог потом переиспользовать эти знания check.updateExprType(tern.Cond, c.typ, true) check.updateExprType(tern.Then, t.typ, true) check.updateExprType(tern.Else, e.typ, true) branchesNil := t.mode == nilvalue && e.mode == nilvalue branchesConst := t.mode == constant_ && e.mode == constant_ // Если мы понимаем, что оба T и F выражения — nil, то тип мы вывести не можем. // Давайте мы это зафиксируем через ранее созданный метод .SetNil() if branchesNil { x.mode = t.mode x.typ = t.typ x.id = t.id x.expr = tern.Then x.val = t.val tern.SetNil() } else if c.mode == constant_ && branchesConst { // А тут пробуем во время компиляции вычислить выражение // Работает если ВСЕ подвыражения константные if constant.BoolVal(c.val) { x.typ = t.typ x.mode = t.mode x.expr = tern.Then x.val = t.val } else { x.typ = e.typ x.mode = e.mode x.expr = tern.Else x.val = e.val } } else { x.typ = t.typ x.mode = value // Результат выражения – вычисленное значение, потом вычислим :) x.expr = tern } }
В какой-то момент я понял, что хочу в Go уметь вычислять константы во время компиляции с помощью терарного выражений. Этакий consteval из C++ на минималках. На самом деле, чтобы его реализовать надо дописать последние 20 сток в сниппете выше — достаточно несложно. Дальше пайплайн компиляции подхватывает, что это константа, и поддерево синтаксического дерева перестаёт использоваться целиком, важна только его вершина с вычисленным значением.
Вызов функции выше стоит поместить внутрь exprInternal — она в свою очередь будет вызываться при проверке и выводе типов выражений.
func (check *Checker) exprInternal(T *target, x *operand, e syntax.Expr, hint Type) exprKind { // make sure x has a valid state in case of bailout // (was go.dev/issue/5770) x.mode = invalid x.typ = Typ[Invalid] switch e := e.(type) { case nil: panic("unreachable") case *syntax.TernaryExpr: check.ternary(T, x, e) // ... }
Поздравляю, теперь мы почти в состоянии проставить корректный возвращаемый тип данных для условного выражения! Осталось еще одна деталь, но она нам встретится в следующем этапе
P.S. И... да, у тайпчекера тоже есть реализация с API для сторонних инструментов. Её нужно синхронизировать с основной версией
На текущий момент мы вычитали наше условное выражение, собрали его как вершину Concrete Syntax Tree, запустили на ней некоторые проверки типов и даже научились вычислять во время компиляции выражения с константами. Следующим шагом, согласно схеме выше, должно быть преобразование вершин этого дерева в привычные вершины абстрактного синтаксического дерева. Хочется отметить, что по ходу этапов компиляции в middle-end-е именно эта структура будет использоваться в качестве промежуточного представления программы.
Эти преобразования в пакете cmd/compile/internal/ir тоже не представляют огромной сложности — из трёх дочерних вершин нужно уметь собирать AST-вершину для условного выражения.
type TernaryExpr struct { miniExpr Cond, Then, Else Node } func NewTernaryExpr(pos src.XPos, typ *types.Type, c, t, e Node) *TernaryExpr { n := &TernaryExpr{} n.op = OTERNARY // в файле ir/node.go нужно завести константу для типа операции n.pos = pos n.typ = typ n.Cond = c n.Then = t n.Else = e return n }
Чтобы передать промежуточное представление между front-end-ом и middle-end-ом компилятора, нужно научиться его читать и писать. Это можно представить как очередной, но более простой парсинг последовательности токенов. Но перед каждой инструкцией, операцией, выражением, ... стоит специальный токен — некоторый код, с помощью которого можно однозначно понять, что нужно дальше парсить — if-statement, логическое выражение или что-либо ещё

В пакете cmd/compile/internal/noder как раз есть соответствующие структуры для работы с этим форматом. Можно туда добавить и методы для чтения и записи
// noder/writer.go // Пишем из CST в IR, которое будем потом читать как AST func (w *writer) ternary(expr *syntax.TernaryExpr) { // Важно соблюсти порядок чтения как в записи, // иначе баги будут труднопонимаемыми :) // Если вычитать вместо Else-выражение Then, то ветви свапнутся w.Code(exprTernary) // в файл codes.go нужно добавить значение новой операции w.expr(expr.Cond) w.expr(expr.Then) w.expr(expr.Else) } // expr writes the given expression into the function body bitstream. func (w *writer) expr(expr syntax.Expr) { base.Assertf(expr != nil, "missing expression") expr = syntax.Unparen(expr) // skip parens; unneeded after typecheck obj, inst := lookupObj(w.p, expr) targs := inst.TypeArgs if tern, ok := expr.(*syntax.TernaryExpr); ok { w.ternary(tern) return } //... }
// noder/reader.go func (r *reader) ternary() ir.Node { condNode := r.expr() thenNode := r.expr() elseNode := r.expr() ternary := ir.NewTernaryExpr(condNode.Pos(), thenNode.Type(), condNode, thenNode, elseNode) setType(ternary, thenNode.Type()) // Проставим вершине тип из then-выражения return ternary } func (r *reader) expr() (res ir.Node) { defer func() { if res != nil && res.Typecheck() == 0 { base.FatalfAt(res.Pos(), "%v missed typecheck", res) } }() switch tag := codeExpr(r.Code(pkgbits.SyncExpr)); tag { default: panic("unhandled expression") case exprTernary: return r.ternary() //... } }
Попутно с написанием кода выше можно наткнуться на неплохие автоматизации разработки компилятора Go — например, некоторые базовые методы для вершин AST можно автоматически сгенерировать через go generate или схожие команды. Аналогичные вещи можно встретить и в более ранних частях front-end-а компиляции, просто нам они не пригодились.
Возвращаясь к некоторой тонкости с проверкой типов — в следующей инструкции var x []int = if true { nil } else { nil } мы сейчас в самом условном выражении не знаем, какой тип оно будет иметь. Пока это всё ещё nil без типа.
И вот на этапе проверки промежуточного представления мы можем исправить эту проблему — «протолкнуть» тип []int в условное выражение. Для этого надо воспользоваться ранее полученным знанием, что мы поставили условному выражению SetNil и на этапе просмотра его значения — вернуть этот самый nil, чтобы компилятор не разломался.
// src/cmd/compile/internal/noder/writer.go func lookupObj(p *pkgWriter, expr syntax.Expr) (obj types2.Object, inst types2.Instance) { if index, ok := expr.(*syntax.IndexExpr); ok { args := syntax.UnpackListExpr(index.Index) if len(args) == 1 { tv := p.typeAndValue(args[0]) if tv.IsValue() { return // normal index expression } } expr = index.X } if tern, ok := expr.(*syntax.TernaryExpr); ok && tern.IsNil() { obj = &types2.Nil{} return } // ... }
А затем в функции для приведения преобразований типа, проставить для условного выражения тип, который мы получаем сверху. Например, из присваивания в духе var x []int = ...
// src/cmd/compile/internal/typecheck/const.go // Функция для преобразований func convlit1(n ir.Node, t *types.Type, explicit bool, context func() string) ir.Node { if explicit && t == nil { base.Fatalf("explicit conversion missing type") } if t != nil && t.IsUntyped() { base.Fatalf("bad conversion to untyped: %v", t) } if n == nil || n.Type() == nil { // Allow sloppy callers. return n } if !n.Type().IsUntyped() { // Already typed; nothing to do. return n } // Nil is technically not a constant, so handle it specially. if n.Type().Kind() == types.TNIL { // И вот тут мы можем протолкнуть тип if n.Op() == ir.OTERNARY { n.SetType(t) return n } // ... }
Отвечая на возможный вопрос «А можно ли это упростить до простой подстановки nil?», отвечу, что не всегда — условие в тернарном выражении может иметь побочные эффекты, и при такой подстановке мы от них избавимся, это непрозрачно и некорректно.
P.S. Честно, вообще хотелось на этот случай забить, но он такой один, и всё же я решил его обработать
С проверкой типов получилось немного сумбурно, потому что она будто «размазана» по компилятору. Но нам осталось совсем немного, чтобы наконец добавить условное выражение в язык!
Одним из следующих этапов является escape-анализ — именно здесь компилятор решает, какие значения надо хранить в куче, какие на стеке, а какие — вообще не хранить. Что мы после исполнения тернарного выражения вообще хотим сохранить?

Справедливо суждение, что результат вычисления условия C нам никуда сохранять не надо — это временное значение. А вот значения выражений T и F претендуют на запись... куда-то наверх, откуда мы пришли. Может быть, снова в локацию для временного значения, может быть в переменную, но это известно только наверху. В любом случае, между локацией для результата выражения и подвыражениями T и F надо установить некоторую связь.
Для исполнения вышеописанных операций, в файле src/cmd/compile/internal/escape/expr.go надо дописать обработку подвыражений для новоиспечённого условного выражения
func (e *escape) exprSkipInit(k hole, n ir.Node) { if n == nil { return } lno := ir.SetPos(n) defer func() { base.Pos = lno }() if k.derefs >= 0 && !n.Type().IsUntyped() && !n.Type().HasPointers() { k.dst = &e.blankLoc } switch n.Op() { default: base.Fatalf("unexpected expr: %s %v", n.Op().String(), n) case ir.OTERNARY: n := n.(*ir.TernaryExpr) e.discard(n.Cond) // Значение C игнорируем e.expr(k, n.Then) // Then-выражение претендует на запись в k e.expr(k, n.Else) // как и выражение Else //... }
За упущением нескольких не самых интересных деталей мы подходим к самому главному — а как вычислять условное выражение? Мы уже сделали это для константных значений, но это капля в море, надо и во время исполнения научиться его считать.
В ранее упомянутом пайплайне компиляции Go есть стадия с данным мной названием «Desugaring». На её этапе происходит переписывание сложных конструкций в более простые упорядоченные инструкции для дальнейшей компиляции. К примеру
type-switch переписывается в бинарный поиск по отсортированным хэшам его кейсов
операции && и ||, которые в этом посте выше приводились как выражение с short-circuited вычислением, переписываются через инструкции с if-statement и присваиваниями временным значениям
Хм, а можем ли мы сделать что-то подобное? Если под капотом компилятора мы сможем переписать условное выражение через if-statement, то, кажется, мы достигнем своей цели! Давайте подобно тому, как это сделано с операциями && и ||, реализуем обработку условного выражения.
Прыгаем в пакет src/cmd/compile/internal/walk, в котором происходит переваривание синтаксического сахара. В методе expr1 для структуры orderState, которая используется для переписывания конструкций в более простые упорядоченные инструкции, добавим обработку условного выражения. Буквально — выделим временное значение r с типом условного выражения, и напишем if-statement, который в зависимости от истинности условия C будет присваивать r либо вычисленное значение then-ветки, либо else.
// src/cmd/compile/internal/walk/order.go func (o *orderState) expr1(n, lhs ir.Node) ir.Node { o.init(n) switch n.Op() { default: if o.edit == nil { o.edit = o.exprNoLHS // create closure once } ir.EditChildren(n, o.edit) return n case ir.OTERNARY: n := n.(*ir.TernaryExpr) // ... = if c { a } else { b } // // var r typeOf(a) // if c { // r = a // } else { // r = b // } r := o.newTemp(n.Type(), false) cond := o.expr(n.Cond, nil) ifstmt := ir.NewIfStmt( base.Pos, cond, []ir.Node{ir.NewAssignStmt(base.Pos, r, n.Then)}, // r = a []ir.Node{ir.NewAssignStmt(base.Pos, r, n.Else)}, // r = b ) o.append(ifstmt) return r
Есть и другие варианты реализации условного выражения здесь — например, можно его переписывать в вызов анонимной функции на месте с этим же if-statement. Тоже будет работать, но медленее, и реализуется это сложнее. Самые заинтересованные могут найти мои попытки такой реализации)
Ну и... В общем-то это всё, что нужно для реализации условного выражения. Если углубляться в детали ещё сильнее, то при написании тестов можно найти случаи, когда уже написанную обработку условного выражения необходимо добавить ещё куда-то. Но писать про это скучновато.
Давайте посмотрим, что у нас в итоге получилось. Ниже я подсветил этапы пайплайна компиляции, которые мы существенно обновили по ходу добавления условного выражения в Go.

Мой код, который можно посмотреть в Pull Request-e, занял менее 800 строк вместе с тестами Да, наверняка ещё есть непокрытые случаи (учитывая, что select-statement в Go не проверяет исчерпываемость всех кейсов, что важно при обходе AST-дерева), но результат уже выглядит прикольно
Запуск ранее приведённого примера кода заканчивается вполне ожидаемым результатом
$ ./make.bash Building Go cmd/dist using /usr/local/go. (go1.25.0 darwin/arm64) Building Go toolchain1 using /usr/local/go. Building Go bootstrap cmd/go (go_bootstrap) using Go toolchain1. Building Go toolchain2 using go_bootstrap and Go toolchain1. Building Go toolchain3 using go_bootstrap and Go toolchain2. Building packages and commands for darwin/arm64. --- Installed Go for darwin/arm64 in /Users/matveykorinenko/projects/thesis-go Installed commands in /Users/matveykorinenko/projects/thesis-go/bin *** You need to add /Users/matveykorinenko/projects/thesis-go/bin to your PATH. $ cd ../bin $ ./go run main.go &{Adult Matvey 21}
Вычисление констант, пускай и со странным примером, тоже работает
package main import "fmt" const Ok = true const Cond = false || true // Я честно не знал, что написать, поэтому будут какие-то странные строки) const ( stateString = if Ok { "ALL_OK" } else { "NO_OK" } statusString = "Status is " + stateString complexCond = "State: " + if Ok && Cond { "Complex cond satisfied" } else { "Complex cond failed" } ) func main() { fmt.Println(statusString) fmt.Println(complexCond) }
$ ./go run main.go Status is ALL_OK State: Complex cond satisfied
Спасибо большое, что уделили внимание моему посту ❤️ Реализация такой самоделки лично мне позволила применить теоретические знания в разработке компиляторов на практике, причем в довольно обширном проекте. Возможно, я и кого-то другого смог убедить в том, что реализовать интересный функционал компилятора на определённом уровне — в целом, вполне себе выполнимая задача) Но, если честно, мне хочется верить, что нам всё же не придётся по разным причинам fork-ать компилятор Go и дорабатывать его под свои нужды; так что я рад, что мой пост будет в лучшем случае иметь образовательный характер)
В конце хотелось бы привести несколько интересных ссылок. Здесь есть идейно похожие работы, но на более старых версиях компилятора Go. Я на них опирался, но многое всё равно пришлось изучать самому
Ну и всё, что по ходу поста я процитировал :-)