Skip to content

Proposal: Function Decorators

xushiwei edited this page Jun 25, 2026 · 9 revisions

This proposal introduces function decorators to XGo — a syntactic mechanism for wrapping functions and methods using higher-order helper functions. XGo decorators are resolved and expanded by the compiler, producing idiomatic Go code with no additional overhead from the decoration mechanism itself.


Motivation

Many common cross-cutting concerns — retry logic, logging, timing, access control, transaction management — are currently expressed in XGo (and Go) through manual wrapping or code duplication. For example:

func Foo(arg1 ArgT1, arg2 ArgT2, ...) (ret1 RetT1, ret2 RetT2, ...) {
    Retry(3, func() {
        ret1, ret2, ... = _actualFoo(arg1, arg2, ...)
    })
}

This boilerplate is error-prone and obscures intent. A decorator syntax lets programmers express the wrapping declaratively, while the compiler performs the expansion transparently.

Decorators are especially valuable in classfile-based frameworks, where a framework author can provide decorator functions that end users apply to their domain methods — without requiring reflection, code generation tools, or runtime overhead.


Proposed Syntax

A decorator is written as @name or @name(args...) immediately before a function or method declaration:

@decoratorName(arg1, arg2, ...)
func Foo(...) (...) { ... }

@decoratorName(arg1, arg2, ...)
func (recv *T) Bar(...) (...) { ... }

Multiple decorators may be stacked. They are applied innermost-first (bottom-up), consistent with Python's convention:

@decoratorA
@decoratorB(x)
func Foo(...) (...) { ... }

This is equivalent to applying @decoratorB(x) first, then @decoratorA around the result.

In XGo, function calls encourage lowercase-initial names as an alternative to uppercase. For example, Foo(arg1, arg2) and foo(arg1, arg2) are equivalent at the call site. This convention applies to decorators as well — @Retry(3) and @retry(3) are interchangeable. The examples in this proposal use lowercase decorator references.


Decorator Function Signature

XGo supports two forms of decorator function signature, distinguished by the type of the trailing closure parameter.

Form 1: fn func()

The last parameter is a func() closure. The compiler generates a closure that captures all named return variables of the decorated function or method, so the decorator does not need to know the return types.

// Zero-argument decorator
func Log(fn func()) { ... }

// Parameterized decorator
func Timeout(d time.Duration, fn func()) { ... }

This form is simple and sufficient when the decorator only needs to control when or whether the function runs, without inspecting its return values.

Form 2: fn func() error

The last parameter is a func() error closure. The compiler generates a closure that calls the original function or method, assigns its return values via named returns, and returns the last return value if it is of type error, or nil otherwise.

// Decorator that can inspect the error return value
func Retry(times int, fn func() error) { ... }

This form allows the decorator to observe whether the wrapped call succeeded and act accordingly — for example, retrying on non-nil error. It does not require reflection.

The func() error closure generated by the compiler:

  • Calls _xgodeco_<FuncName> (or _xgodeco_<MethodName> for methods) and assigns all return values via named returns.
  • Returns the error-typed return value if one exists in the decorated function's signature (the last return value of type error is used by convention).
  • Returns nil if the decorated function has no error return value (making Form 2 a valid but redundant alternative to Form 1 in that case).

Compiler Transformation

Form 1: fn func() expansion

Given:

func Log(fn func()) { ... }

@log
func Foo(arg1 ArgT1, arg2 ArgT2, ...) (ret1 RetT1, ret2 RetT2, ...) {
    // original body
}

The compiler produces:

func _xgodeco_Foo(arg1 ArgT1, arg2 ArgT2, ...) (ret1 RetT1, ret2 RetT2, ...) {
    // original body
}

func Foo(arg1 ArgT1, arg2 ArgT2, ...) (ret1 RetT1, ret2 RetT2, ...) {
    Log(func() {
        ret1, ret2, ... = _xgodeco_Foo(arg1, arg2, ...)
    })
    return
}

For a method, the transformation is identical in structure — the receiver is preserved on both the renamed original and the wrapper:

func Log(fn func()) { ... }

@log
func (recv *T) Bar(arg1 ArgT1, ...) (ret1 RetT1, ...) {
    // original body
}

Produces:

func (recv *T) _xgodeco_Bar(arg1 ArgT1, ...) (ret1 RetT1, ...) {
    // original body
}

func (recv *T) Bar(arg1 ArgT1, ...) (ret1 RetT1, ...) {
    Log(func() {
        ret1, ... = recv._xgodeco_Bar(arg1, ...)
    })
    return
}

Key points:

  • The original body moves to _xgodeco_<Name> (function) or _xgodeco_<Name> method on the same receiver type.
  • The public wrapper uses named returns so the closure can assign them.
  • If there are no return values, the closure body is a plain call with no assignment.

Form 2: fn func() error expansion

Given:

func Retry(times int, fn func() error) { ... }

@retry(3)
func Foo(arg1 ArgT1, arg2 ArgT2, ...) (ret1 RetT1, ret2 RetT2, ..., err error) {
    // original body
}

The compiler produces:

func _xgodeco_Foo(arg1 ArgT1, arg2 ArgT2, ...) (ret1 RetT1, ret2 RetT2, ..., err error) {
    // original body
}

func Foo(arg1 ArgT1, arg2 ArgT2, ...) (ret1 RetT1, ret2 RetT2, ..., err error) {
    Retry(3, func() error {
        ret1, ret2, ..., err = _xgodeco_Foo(arg1, arg2, ...)
        return err
    })
    return
}

If the decorated function has no error return value, the generated closure returns nil:

func Foo(arg1 ArgT1, arg2 ArgT2, ...) (ret1 RetT1, ret2 RetT2, ...) {
    Retry(3, func() error {
        ret1, ret2, ... = _xgodeco_Foo(arg1, arg2, ...)
        return nil
    })
    return
}

The same pattern applies to methods, with the receiver carried through to the renamed method.

Stacked Decorators

Stacked decorators always produce exactly two functions (or two methods): _xgodeco_<Name> and the public <Name>. The compiler selects the expansion form for each decorator independently based on its signature, and nests them bottom-up inline.

@log
@retry(3)
func Foo(arg1 ArgT1) (ret1 RetT1, err error) { ... }

Here @retry uses Form 2 and @log uses Form 1. Expands to:

func _xgodeco_Foo(arg1 ArgT1) (ret1 RetT1, err error) { ... }

func Foo(arg1 ArgT1) (ret1 RetT1, err error) {
    Log(func() {
        Retry(3, func() error {
            ret1, err = _xgodeco_Foo(arg1)
            return err
        })
    })
    return
}

Decorator Resolution

The compiler resolves decorator names using the following lookup order:

1. Current Package

The compiler first looks for the decorator function in the same package as the decorated function or method. This covers the common case where decorators are defined alongside the code that uses them.

2. Class Framework Package (Classfile Context)

If the decorated function or method resides inside a classfile, the compiler additionally searches the class framework package that the classfile belongs to. This allows framework authors to provide a library of standard decorators that classfile users can apply without an explicit import.

This resolution order mirrors how classfile method lookup already works for other framework-provided identifiers.

If no matching decorator is found in either scope, a compiler error is reported.


Decorator Matching Rules

For a decorator @name(a, b, ...) applied to a function or method, the compiler looks for a function named name (case-insensitive first letter, per XGo convention) and determines which form applies based on the type of the trailing parameter:

Form 1 is matched when the last parameter of name is func():

  • The leading parameters of name must match the types of the arguments in @name(a, b, ...).
  • The compiler generates a func() closure capturing all named return variables.

Form 2 is matched when the last parameter of name is func() error:

  • The leading parameters of name must match the types of the arguments in @name(a, b, ...).
  • The compiler generates a func() error closure that assigns all return values and returns the error-typed return value (or nil if none).

If neither form matches, or no function named name is found in the resolution scope, a compiler error is reported.


Examples

Retry on Error

Retry needs to inspect the error return value to decide whether to retry. It uses Form 2:

// Framework or package definition
func Retry(times int, fn func() error) {
    for i := 0; i < times; i++ {
        if fn() == nil {
            return
        }
    }
}

// User code — plain function
@retry(3)
func FetchData(url string) (data []byte, err error) {
    data, err = http.Get(url)
    return
}

Compiled output:

func _xgodeco_FetchData(url string) (data []byte, err error) {
    data, err = http.Get(url)
    return
}

func FetchData(url string) (data []byte, err error) {
    Retry(3, func() error {
        data, err = _xgodeco_FetchData(url)
        return err
    })
    return
}

Logging

Log does not need to inspect return values. It uses Form 1:

func Log(fn func()) {
    log.Println("entering")
    fn()
    log.Println("exiting")
}

@log
func Process(input string) (result string) {
    result = strings.ToUpper(input)
    return
}

Compiled output:

func _xgodeco_Process(input string) (result string) {
    result = strings.ToUpper(input)
    return
}

func Process(input string) (result string) {
    Log(func() {
        result = _xgodeco_Process(input)
    })
    return
}

Method Decorator

Decorators apply to methods in exactly the same way. The receiver is preserved on both the renamed original and the wrapper:

@retry(3)
func (s *Service) Call(req Request) (resp Response, err error) {
    resp, err = s.client.Do(req)
    return
}

Compiled output:

func (s *Service) _xgodeco_Call(req Request) (resp Response, err error) {
    resp, err = s.client.Do(req)
    return
}

func (s *Service) Call(req Request) (resp Response, err error) {
    Retry(3, func() error {
        resp, err = s._xgodeco_Call(req)
        return err
    })
    return
}

Classfile Context

In a classfile-based game engine, the framework package might provide:

// In the framework package (e.g., spx)
func OnMainThread(fn func()) {
    mainThreadQueue <- fn
}

Users can then write in their classfile:

@onMainThread
func UpdateScore(delta int) {
    score += delta
}

Without needing to import or qualify OnMainThread explicitly.


AST Representation

A decorator is represented in the XGo AST as a list of DecoratorSpec nodes attached to FuncDecl:

type DecoratorSpec struct {
    At      token.Pos   // position of '@'
    Name    *Ident      // decorator function name
    Args    []Expr      // arguments (may be empty)
}

type FuncDecl struct {
    // ... existing fields ...
    Decorators []*DecoratorSpec  // nil if no decorators; ordered top-to-bottom
}

Decorators are stored in source order (top to bottom). The compiler applies them bottom-up during transformation.


Restrictions

The following restrictions apply in this initial proposal:

  1. Variadic functions and methods. Decorated functions or methods with variadic parameters (...T) are supported; the compiler passes the slice directly in the generated closure rather than re-spreading it:
    // For: func Foo(args ...int) { ... }
    func Foo(args ...int) {
        Decorator(func() { _xgodeco_Foo(args...) })
    }
  2. No generic functions or methods. Decorating generic (type-parameterized) functions or methods is not supported in this version and results in a compiler error. Generic decorator support requires further design.
  3. Decorator arguments must be constants or package-level expressions. Decorator arguments are evaluated at the call site in the generated wrapper. They may not reference local variables of the decorated function.
  4. No decorator lookup across arbitrary packages. Decorator lookup is limited to the current package and the classfile's framework package. Decorators from other imported packages are not in scope unless explicitly aliased into the current package.

Non-Goals

  • Dynamic decoration. XGo decorators are a compiler rewrite, not a runtime protocol. There is no reflection-based or dynamic decorator mechanism.
  • Class/struct decorators. Decorating type declarations is not in scope.
  • Decorator factories returning decorators. Chained decorator factories (a function that returns a decorator function) are not supported in this proposal.

Summary

Aspect Design
Syntax @name or @name(args...) before func or method
Applies to Functions and methods (including classfile methods)
Application order Bottom-up (innermost decorator applied first)
Expansion Compiler renames original to _xgodeco_<Name>, generates wrapper
Decorator form 1 Trailing fn func() — closure, no access to return values
Decorator form 2 Trailing fn func() error — closure returning the error return value
Resolution scope Current package, then classfile framework package
Generics Not supported (this version)
Runtime overhead None from decoration mechanism itself

Function decorators give XGo a clean, expressive way to apply cross-cutting concerns while remaining fully transparent to the Go type system and toolchain. The generated code is idiomatic Go, inspectable, and debuggable without any special tooling.

Clone this wiki locally