HardТеория5 min

Visitor

Паттерн Visitor в Go: добавление операций к структурам данных, type switch и обход AST

Visitor (Посетитель)

Проблема

Есть сложная структура данных (AST, документ, дерево) с разными типами узлов. Нужно добавлять новые операции над этими узлами, не изменяя их код. Visitor отделяет алгоритм от структуры, по которой он работает.

Важно: В Go Visitor реализуется проще, чем в Java, благодаря type switch. Классический double dispatch нужен редко.

Диаграмма

    Visitor                      Node
    +-------------------+       +-------------------+
    | VisitNumber(n)    |       | Accept(Visitor)   |
    | VisitString(s)    |       +-------------------+
    | VisitBinary(b)    |              ^
    +-------------------+         +----+----+
           ^                      |    |    |
      +----+----+            Number String Binary
      |         |
   Evaluator  Printer

Подход 1: Type Switch (Go-идиоматичный)

Самый простой и рекомендуемый подход для Go:

package ast

import "fmt"

// Node is any node in the AST.
type Node interface {
    node() // unexported marker method
}

// --- Concrete nodes ---

type NumberLit struct {
    Value float64
}
func (*NumberLit) node() {}

type StringLit struct {
    Value string
}
func (*StringLit) node() {}

type BinaryExpr struct {
    Left  Node
    Op    string
    Right Node
}
func (*BinaryExpr) node() {}

type UnaryExpr struct {
    Op      string
    Operand Node
}
func (*UnaryExpr) node() {}

type CallExpr struct {
    Name string
    Args []Node
}
func (*CallExpr) node() {}

// --- Operations via type switch ---

// Eval evaluates an AST node.
func Eval(n Node) (float64, error) {
    switch v := n.(type) {
    case *NumberLit:
        return v.Value, nil

    case *BinaryExpr:
        left, err := Eval(v.Left)
        if err != nil {
            return 0, err
        }
        right, err := Eval(v.Right)
        if err != nil {
            return 0, err
        }
        switch v.Op {
        case "+":
            return left + right, nil
        case "-":
            return left - right, nil
        case "*":
            return left * right, nil
        case "/":
            if right == 0 {
                return 0, fmt.Errorf("division by zero")
            }
            return left / right, nil
        default:
            return 0, fmt.Errorf("unknown operator: %s", v.Op)
        }

    case *UnaryExpr:
        val, err := Eval(v.Operand)
        if err != nil {
            return 0, err
        }
        switch v.Op {
        case "-":
            return -val, nil
        default:
            return 0, fmt.Errorf("unknown unary operator: %s", v.Op)
        }

    default:
        return 0, fmt.Errorf("cannot evaluate node type %T", n)
    }
}

// Print returns a string representation of the AST.
func Print(n Node) string {
    switch v := n.(type) {
    case *NumberLit:
        return fmt.Sprintf("%.g", v.Value)
    case *StringLit:
        return fmt.Sprintf("%q", v.Value)
    case *BinaryExpr:
        return fmt.Sprintf("(%s %s %s)", Print(v.Left), v.Op, Print(v.Right))
    case *UnaryExpr:
        return fmt.Sprintf("(%s%s)", v.Op, Print(v.Operand))
    case *CallExpr:
        args := make([]string, len(v.Args))
        for i, a := range v.Args {
            args[i] = Print(a)
        }
        return fmt.Sprintf("%s(%s)", v.Name, strings.Join(args, ", "))
    default:
        return fmt.Sprintf("<%T>", n)
    }
}

Использование:

func main() {
    // Represents: (2 + 3) * 4
    expr := &ast.BinaryExpr{
        Left: &ast.BinaryExpr{
            Left:  &ast.NumberLit{Value: 2},
            Op:    "+",
            Right: &ast.NumberLit{Value: 3},
        },
        Op:    "*",
        Right: &ast.NumberLit{Value: 4},
    }

    fmt.Println(ast.Print(expr))  // ((2 + 3) * 4)
    result, _ := ast.Eval(expr)
    fmt.Println(result)            // 20
}

Подход 2: Классический Visitor с Accept

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

package document

// Visitor defines operations for each node type.
type Visitor interface {
    VisitParagraph(p *Paragraph)
    VisitHeading(h *Heading)
    VisitImage(img *Image)
    VisitTable(t *Table)
    VisitCodeBlock(c *CodeBlock)
}

// Element is a document node that accepts visitors.
type Element interface {
    Accept(v Visitor)
}

// --- Concrete elements ---

type Paragraph struct {
    Text string
}

func (p *Paragraph) Accept(v Visitor) { v.VisitParagraph(p) }

type Heading struct {
    Level int
    Text  string
}

func (h *Heading) Accept(v Visitor) { v.VisitHeading(h) }

type Image struct {
    URL string
    Alt string
}

func (img *Image) Accept(v Visitor) { v.VisitImage(img) }

type Table struct {
    Headers []string
    Rows    [][]string
}

func (t *Table) Accept(v Visitor) { v.VisitTable(t) }

type CodeBlock struct {
    Language string
    Code     string
}

func (c *CodeBlock) Accept(v Visitor) { v.VisitCodeBlock(c) }

// Document is a collection of elements.
type Document struct {
    Elements []Element
}

func (d *Document) Accept(v Visitor) {
    for _, elem := range d.Elements {
        elem.Accept(v)
    }
}

Конкретные посетители

package export

import (
    "fmt"
    "strings"
    "myapp/document"
)

// HTMLExporter converts a document to HTML.
type HTMLExporter struct {
    builder strings.Builder
}

func (e *HTMLExporter) VisitParagraph(p *document.Paragraph) {
    fmt.Fprintf(&e.builder, "<p>%s</p>\n", p.Text)
}

func (e *HTMLExporter) VisitHeading(h *document.Heading) {
    fmt.Fprintf(&e.builder, "<h%d>%s</h%d>\n", h.Level, h.Text, h.Level)
}

func (e *HTMLExporter) VisitImage(img *document.Image) {
    fmt.Fprintf(&e.builder, `<img src="%s" alt="%s" />`+"\n", img.URL, img.Alt)
}

func (e *HTMLExporter) VisitTable(t *document.Table) {
    e.builder.WriteString("<table>\n<tr>")
    for _, h := range t.Headers {
        fmt.Fprintf(&e.builder, "<th>%s</th>", h)
    }
    e.builder.WriteString("</tr>\n")
    for _, row := range t.Rows {
        e.builder.WriteString("<tr>")
        for _, cell := range row {
            fmt.Fprintf(&e.builder, "<td>%s</td>", cell)
        }
        e.builder.WriteString("</tr>\n")
    }
    e.builder.WriteString("</table>\n")
}

func (e *HTMLExporter) VisitCodeBlock(c *document.CodeBlock) {
    fmt.Fprintf(&e.builder, "<pre><code class=\"%s\">%s</code></pre>\n", c.Language, c.Code)
}

func (e *HTMLExporter) Result() string {
    return e.builder.String()
}

// MarkdownExporter converts a document to Markdown.
type MarkdownExporter struct {
    builder strings.Builder
}

func (e *MarkdownExporter) VisitParagraph(p *document.Paragraph) {
    fmt.Fprintf(&e.builder, "%s\n\n", p.Text)
}

func (e *MarkdownExporter) VisitHeading(h *document.Heading) {
    fmt.Fprintf(&e.builder, "%s %s\n\n", strings.Repeat("#", h.Level), h.Text)
}

func (e *MarkdownExporter) VisitImage(img *document.Image) {
    fmt.Fprintf(&e.builder, "![%s](%s)\n\n", img.Alt, img.URL)
}

func (e *MarkdownExporter) VisitTable(t *document.Table) {
    // Header
    e.builder.WriteString("| " + strings.Join(t.Headers, " | ") + " |\n")
    e.builder.WriteString("|" + strings.Repeat(" --- |", len(t.Headers)) + "\n")
    for _, row := range t.Rows {
        e.builder.WriteString("| " + strings.Join(row, " | ") + " |\n")
    }
    e.builder.WriteString("\n")
}

func (e *MarkdownExporter) VisitCodeBlock(c *document.CodeBlock) {
    fmt.Fprintf(&e.builder, "```%s\n%s\n```\n\n", c.Language, c.Code)
}

func (e *MarkdownExporter) Result() string {
    return e.builder.String()
}

Использование

func main() {
    doc := &document.Document{
        Elements: []document.Element{
            &document.Heading{Level: 1, Text: "Hello Go"},
            &document.Paragraph{Text: "Go is a great language."},
            &document.CodeBlock{Language: "go", Code: `fmt.Println("Hello!")`},
        },
    }

    // Export to HTML
    html := &export.HTMLExporter{}
    doc.Accept(html)
    fmt.Println(html.Result())

    // Export to Markdown -- new operation, NO changes to document package
    md := &export.MarkdownExporter{}
    doc.Accept(md)
    fmt.Println(md.Result())
}

Type Switch vs Classic Visitor

Критерий Type Switch Classic Visitor
Добавить операцию Новая функция (просто) Новый Visitor (просто)
Добавить тип узла Обновить все switch (сложно) Обновить Visitor интерфейс (сложно)
Проверка полноты Нет (runtime) Да (compile-time)
Идиоматичность Go Высокая Средняя
Лучше когда Типы стабильны, операции меняются Операции стабильны, типы стабильны

Когда использовать

Используйте, когда:

  • Сложная структура данных с разными типами узлов (AST, DOM)
  • Нужно добавлять новые операции без изменения узлов
  • Набор типов узлов стабилен (редко добавляются)
  • Экспорт в разные форматы (HTML, PDF, Markdown)

Не используйте, когда:

  • Типы узлов часто добавляются (нужно обновлять Visitor)
  • Простая структура (type switch достаточен)
  • Одна операция (нет смысла в абстракции)

Сравнение с похожими паттернами

Паттерн Отличие
Strategy Выбор одного алгоритма; Visitor -- разные операции для разных типов
Iterator Обходит элементы; Visitor выполняет операции при обходе
Composite Определяет структуру дерева; Visitor добавляет операции к дереву

Проверь себя

Какой подход к Visitor более идиоматичен в Go?

Когда классический Visitor (с Accept) предпочтительнее type switch?

В чём главный trade-off паттерна Visitor?