Skip to content

proposal: spec: lazy values #37739

@ianlancetaylor

Description

@ianlancetaylor

This is just a thought. I'm interested in what people think.

Background:

Go has two short circuit binary operators, && and ||, that only evaluate their second operand under certain conditions. There are periodic requests for additional short circuit expressions, often but not only the ?: ternary operator found originally in C; for example: #20774, #23248, #31659, #32860, #33171, #36303, #37165.

There are also uses for short circuit operation in cases like conditional logging, in which the operands to a logging function are only evaluated if the function will actually log something. For example, calls like

    log.Verbose("graph depth %d", GraphDepth(g))

where log.Verbose only logs a message if some command line flag is specified, and GraphDepth is an expensive operation.

To be clear, all short circuit operations can be expressed using if statements (or occasionally && or || operators). But this expression is inevitably more verbose, and can on occasion overwhelm more important elements of the code.

    if log.VerboseLogging() {
        log.Verbose("graph depth %d", GraphDepth(g))
    }

In this proposal I consider a general mechanism for short circuiting.

Discussion:

Short circuiting means delaying the evaluation of an expression until and unless the value of that expression is needed. If the value of the expression is never needed, then the expression is never evaluated.

(In this discussion it is important to clearly understand the distinction that Go draws between expressions (https://golang.org/ref/spec#Expressions) and statements (https://golang.org/ref/spec#Statements). I'm not going to elaborate on that here but be aware that when I write "expression" I definitely do not mean "statement".)

In practice the only case where we are interested in delaying the evaluation of an expression is if the expression is a function call. All expressions other than function calls complete in small bounded time and have no side effects (other than memory allocation and panicking). While it may occasionally be nice to skip the evaluation of such an expression, it will rarely make a difference in program behavior and will rarely take a noticeable amount of time. It's not worth changing the language to short circuit the evaluation of any expression other than a function call.

Similarly, in practice the only case where we are interested in delaying the evaluation of an expression is when passing that expression to a function. In all other cases the expression is evaluated in the course of executing the statement or larger expression in which it appears (other than, of course, the && and || operators). There is no point to delaying the evaluation of expression when it is going to be evaluated very shortly in any case. (Here I am intentionally ignoring the possibility of adding additional short circuit operators, like ?:, to the language; the language does not have those operators today, and we could add them without affecting this proposal.)

So we are only interested in the ability to delay the evaluation of a function call that is being passed as an argument to some other function.

In order for the language to remain comprehensible to the reader, it is essential that any delay in evaluation be clearly marked at the call site. One could in principle permit extending function declarations so that some or all arguments are evaluated lazily, but that would not be clear to the reader when calling the function. It would mean that when reading a call like Lazy(F()) the reader would have to be aware of the declaration of Lazy to know whether the call F() would be evaluated. That would be a recipe for confusion.

But at the same time the fact that Go is a compiled type safe language means that the function declaration has to be aware that it will receive an expression that will be evaluated lazily. If a function takes an bool argument, we can't pass in a lazily evaluated function call. That can't be expressed as a bool, and there would be no way for the function to request evaluation at the appropriate time.

So what we are talking about is something akin to C++ std::future with std::launch::deferred or Rust futures::future::lazy.

In Go this kind of thing can be done using a function literal. The function that wants a lazy expression takes an argument of type func() T for some type T, and when it needs the value it calls the function literal. At the call site people write func() T { return F() } to delay the evaluation of F until the point where it is needed.

So we can already do what we want. But it's unsatisfactory because it's verbose. At the call site it's painful to have to write a function literal each time. It's especially painful if some calls require lazy evaluation and some do not, as the function literal must be written out either way. In the function that takes the lazy expression, it's annoying to have to explicitly invoke the function, especially if all you want to do is pass the value on to something like fmt.Sprintf.

Proposal:

We introduce a new kind of type, a lazy type, represented as lazy T. This type has a single method Eval() that returns a value of type T. It is not comparable, except to nil. The only supported operation, other than operations like assignment or unary & that apply to all types, is to call the Eval method. This has some similarities to the type

interface {
    Eval() T
}

but it is not the same as regards type conversion.

A value v of type T may be implicitly converted to the type lazy T. This produces a value whose Eval() method returns v. A value v of type T1 may be implicitly converted to the type lazy T2 if T1 may be implicitly converted to T2. In this case the Eval() method returns T2(v). Similarly, a value v of type lazy T1 may be implicitly converted to the type lazy T2 if T1 may be implicitly converted to T2. In this case the Eval() method returns T2(v.Eval()).

We introduce a new kind of expression, lazy E. This expression does not evaluate E. Instead, it returns a value of type lazy T that, when the Eval method is first called, evaluates E and returns the value to which it evaluates. If evaluation of E panics, then the panic occurs when the Eval method is first called. Subsequent calls of the Eval method return the same value, without evaluating the expression again.

For convenience, if the various fmt functions see a value of type lazy T, they will call the Eval method and handle the value as though it has type T.

Some additions will be needed to the reflect package. Those are not yet specified.

The builtin functions panic, print, and println, will not call the Eval method of a value of type lazy T. If a value of type lazy T is passed to panic, any relevant recover will return a value of that type.

That is the entire proposal.

Examples:

This permits writing

package log
func Verbose(format string, a ...lazy interface{}) {
    if verboseFlag {
        log.Info(format, a...)
    }
}

Calls to the function will look like

    log.Verbose("graph depth %d", lazy GraphDepth(g))

The GraphDepth function will only be called if verboseFlag is true.

Note that it is also fine to write

    log.Verbose("step %d", step)

without having to write lazy step, using the implicit conversion to lazy T.

If we adopt some form of generics, this will permit writing (edited original proposal to use current syntax):

func Cond[T any](cond bool, v1 lazy T, v2 lazy T) T {
    if cond {
        return v1.Eval()
    }
    return v2.Eval()
}

This will be called as

    v := cond(useTls, lazy FetchCertificate(), nil)

In other words, this is a variant of the ?: operator, albeit one that requires explicit annotation for values that should be lazily evaluated.

Note:

This idea is related to the idea of a promise as found in languages like Scheme, Java, Python, C++, etc. A promise would look like this (edited original proposal to use current syntax):

func Promise[T any](v lazy T) chan T {
    c := make(chan T, 1)
    go func() { c <- v.Eval() }()
    return c
}

A promise would be created like this:

    F(promise.Promise(lazy F())

and used like this:

func F(c chan T) {
    // do stuff
    v := <-c
    // do more stuff
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions