Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ExprNative interface - Allow easier use of custom types that can be represented by go builtin types #460

Closed
wants to merge 2 commits into from

Conversation

rrb3942
Copy link
Contributor

@rrb3942 rrb3942 commented Nov 1, 2023

This patch set adds the ExprNative interface which allows a type to present itself as a expr native/friendly type for evaluation.

type ExprNative interface {
	// ExprNativeValue returns a native value that expr can use directly
	ExprNativeValue() any
	// ExprNativeType returns the reflect.Type of the type that will be returned by ExprNativeValue
	ExprNativeType() reflect.Type
}

This interface currently lives in the vm package. Not sure if there is a better placement for it.

It is controlled by the ExprNative() Option. This is set at compile time and is a Program level flag.

// ExprNative sets a flag in compiled program teling the runtime to use the value return by ExprNative interface instead of the direct variable
// This option must be passed BEFORE the Env() option during the compile phase or type checking will most likely be broken
// Only applies to map environments
func ExprNative(b bool) Option {
        return func(c *conf.Config) {
                c.ExprNative = b
        }
}

The goal is to make it easier to use custom types in expr when they can be represented as builtin go types.

My personal use case is that I am using map[string]interface{} to represent a database record, but the values stored are custom types based on the database column type with additional metadata.

Something along the lines of:

type MyDBInt struct {
        MyInt int
        // Additional Meta Info Here
}

In order to use them with expr there are a few options:

  • Build a new map[string]interface{} env that I populate with the go builtin values to use with calls to expr. However the results of the expression may used change a value in the record, which may mean having to rebuild the env on every call, or additional book keeping.
  • Provide a function for the types to retrieve the builtin value at runtime (which is not the most ergonomic).
  • Overloading, but I have multiple types (representing database column types) and this quickly grows out of hand. Being able to reuse the builtin expr operators and functions is much more ideal.

With the ExprNative interface they can be addressed directly in expressions, which allows me to reuse my already present map[string]interface{} and not have to rebuild or book-keep a separate env.

Performance and Benchmarks

We add a check in vm.push() so that before a value is pushed to the stack it is converted to the native type. vm.push() is hot path location, so we guard the type cast behind a configuration setting to prevent a as much of a performance regression as possible

                         │      sec/op      │   sec/op     vs base                │
_expr                           184.8n ± 2%   189.8n ± 2%   +2.73% (p=0.003 n=10)
_expr_reuseVm                   115.9n ± 1%   120.8n ± 1%   +4.18% (p=0.000 n=10)
_len                            103.0n ± 2%   103.2n ± 1%        ~ (p=0.927 n=10)
_filter                         134.7µ ± 3%   160.0µ ± 1%  +18.78% (p=0.000 n=10)
_filterLen                      121.1µ ± 1%   145.0µ ± 1%  +19.74% (p=0.000 n=10)
_filterFirst                    1.196µ ± 2%   1.352µ ± 2%  +12.95% (p=0.000 n=10)
_filterLast                     2.220µ ± 1%   2.341µ ± 1%   +5.45% (p=0.000 n=10)
_filterMap                      14.13µ ± 1%   16.48µ ± 1%  +16.62% (p=0.000 n=10)
_arrayIndex                     161.0n ± 0%   174.0n ± 3%   +8.04% (p=0.000 n=10)
_envStruct                      124.2n ± 2%   121.7n ± 1%   -2.01% (p=0.000 n=10)
_envMap                         138.3n ± 2%   138.6n ± 1%        ~ (p=0.753 n=10)
_callFunc                       823.1n ± 1%   840.4n ± 1%   +2.10% (p=0.000 n=10)
_callMethod                     869.3n ± 2%   885.2n ± 1%   +1.82% (p=0.000 n=10)
_callField                      160.3n ± 1%   165.8n ± 1%   +3.43% (p=0.000 n=10)
_callFast                       164.2n ± 4%   168.1n ± 1%   +2.34% (p=0.027 n=10)
_callConstExpr                  132.8n ± 1%   130.8n ± 1%   -1.51% (p=0.001 n=10)
_largeStructAccess              315.6n ± 1%   331.4n ± 1%   +5.02% (p=0.000 n=10)
_largeNestedStructAccess        341.8n ± 1%   347.9n ± 2%   +1.81% (p=0.001 n=10)
_largeNestedArrayAccess         1.369m ± 1%   1.366m ± 0%        ~ (p=0.315 n=10)
_sort                           14.82µ ± 1%   14.85µ ± 1%        ~ (p=0.987 n=10)
_sortBy                         25.17µ ± 5%   24.79µ ± 3%        ~ (p=0.139 n=10)
_groupBy                        22.84µ ± 2%   23.53µ ± 1%   +3.06% (p=0.003 n=10)
_reduce                         11.64µ ± 1%   11.67µ ± 1%        ~ (p=0.353 n=10)
geomean                         1.672µ        1.745µ        +4.33%

I'm not sure if there is any way to reduce this further. Or a better place in the pipeline to do the conversion.

I also added some benchmarks to compare it on/off and against calling a function to get the value.

Benchmark_nativeAdd             12200732                93.85 ns/op
Benchmark_nativeEnabledAdd       8604038               136.9 ns/op
Benchmark_exprNativeAdd          8606650               140.9 ns/op
Benchmark_callAdd                 761148              1525 ns/op

The benchmark shows that most of the performance hit from enabling and disabling the option are from the type cast check, and that it is much faster than using a function call on the type.

Anecdotally it is also about 2x faster than creating a new map and converting values for each call to env.

I think it provides a nice usability enhancement to expr and look forward to your feedback.

@antonmedv
Copy link
Member

I understand your idea, clever solution. But penalty for hot path is way too big. Also, the API naming is little bit confusing.

Lets try to find a solution without compromising on speed.

I see you used testOne.Value() in call benchmark. Lets try to replace it with another pattern:

getValue(testOne)

And getValue should be of type func (any) any, this way Expr can use fast call opcode.

Use a expr.Patch to find all variable what implement the interface and patch with a function call.

@rrb3942
Copy link
Contributor Author

rrb3942 commented Nov 2, 2023

Thank you @antonmedv, that is really good feedback and guidance. I wasn't very happy with the hot path change but wasn't aware of a better approach, so I appreciate it.

I'll work on switching this over to a solution using expr.Patch.

@antonmedv
Copy link
Member

Here is the rough idea of what I'm thinking (https://go.dev/play/p/l75AtVEryUe):

package main

import (
	"fmt"
	"reflect"

	"github.com/antonmedv/expr"
	"github.com/antonmedv/expr/ast"
)

type Value struct {
	Int int
}

type Env struct {
	Value Value
}

var valueType = reflect.TypeOf((*Value)(nil)).Elem()

type getValuePatcher struct{}

func (getValuePatcher) Visit(node *ast.Node) {
	id, ok := (*node).(*ast.IdentifierNode)
	if !ok {
		return
	}
	if id.Type() == valueType {
		ast.Patch(node, &ast.CallNode{
			Callee:    &ast.IdentifierNode{Value: "getValue"},
			Arguments: []ast.Node{id},
		})
	}
}

func main() {
	code := `Value + 1`

	program, err := expr.Compile(code,
		expr.Env(Env{}),
		expr.Function("getValue", func(params ...any) (any, error) {
			return params[0].(Value).Int, nil
		}),
		expr.Patch(getValuePatcher{}),
	)
	if err != nil {
		panic(err)
	}

	env := Env{
		Value: Value{1},
	}
	output, err := expr.Run(program, env)
	if err != nil {
		panic(err)
	}

	fmt.Println(output)
}

@rrb3942
Copy link
Contributor Author

rrb3942 commented Nov 3, 2023

The problem that I'm running into with this approach is that type mismatches are not caught at compile time and only at runtime. It's very important for me that as many issues be caught at compile time.

Is there a way to do this that would also hint the compiler about the returned type so type issues could be caught?

@antonmedv
Copy link
Member

It is possible! You can set type in the patcher. Or you can set type in expr.Function:

 expr.Function("getValue", func(params ...any) (any, error) {
 	return params[0].(Value).Int, nil
 },
+new(func (Value) int)),

@rrb3942
Copy link
Contributor Author

rrb3942 commented Nov 4, 2023

Here is an example pattern that I came up with using interfaces:
https://go.dev/play/p/WpdiBXQHcdH

package main

import (
	"fmt"
	"reflect"

	"github.com/antonmedv/expr"
	"github.com/antonmedv/expr/ast"
)

// Generic fetch function for all types
type Valuer interface {
	Value() any
}

// Example interface if multiple types return this value type.
// Instead of iterating every type in the function declaration we use a more specific
// interface that embeds our generic interface and the underlying types implement both
type IntValue interface {
	Valuer
	IntValue() int
}

type FloatValue interface {
	Valuer
	FloatValue() float64
}

// Same as above but for strings
type StringValue interface {
	Valuer
	StringValue() string
}

type MyInt struct {
	Int int
}

func (v *MyInt) IntValue() int {
	return v.Int
}

func (v *MyInt) Value() any {
	return v.Int
}

type MyString struct {
	String string
}

func (v *MyString) StringValue() string {
	return v.String
}

func (v *MyString) Value() any {
	return v.String
}

// Only implements Valuer type must be explicitly defined on function
type MyFloat struct {
	Float float64
}

func (v *MyFloat) Value() any {
	return v.Float
}

type Env struct {
	Value any
}

var valuerType = reflect.TypeOf((*Valuer)(nil)).Elem()

type getValuePatcher struct{}

func (getValuePatcher) Visit(node *ast.Node) {
	id, ok := (*node).(*ast.IdentifierNode)
	if !ok {
		return
	}

	if id.Type().Implements(valuerType) {
		ast.Patch(node, &ast.CallNode{
			Callee:    &ast.IdentifierNode{Value: "getValue"},
			Arguments: []ast.Node{id},
		})
	}
}

func main() {
	code := `ValueOne + 1`

	env := make(map[string]any)
	env["ValueOne"] = &MyInt{1}
	env["ValueTwo"] = &MyString{"astring"}
	env["ValueThree"] = &MyFloat{1.2}

	program, err := expr.Compile(code,
		expr.Env(env),
		expr.Function("getValue", func(params ...any) (any, error) {
			// Use our generic fetch function to return the value
			return params[0].(Valuer).Value(), nil
		},
			// Now we can have it check for specific interface types as an input
			// and set the right return value type for checking
			new(func(IntValue) int),
			new(func(StringValue) string),
			// Or explicit types, make sure they implement the generic fetch
			new(func(*MyFloat) float64)),
		expr.Patch(getValuePatcher{}),
	)
	if err != nil {
		panic(err)
	}

	output, err := expr.Run(program, env)
	if err != nil {
		panic(err)
	}

	fmt.Println(output)
}

Still need to run some benchmarks and see how it compares.

@rrb3942
Copy link
Contributor Author

rrb3942 commented Nov 10, 2023

The patching methods works very well for my use case and performance is good. I'm going to close this PR since it can be accomplished already via patching.

If you think it would be helpful to have a builtin interface type for others to use, or want to have some builtin patchers available by default I'd be happy to redo this using the patching method.

Thanks for all the help!

@rrb3942 rrb3942 closed this Nov 10, 2023
@antonmedv
Copy link
Member

Builtin patches seem like a good idea.

@rrb3942
Copy link
Contributor Author

rrb3942 commented Nov 15, 2023

It is possible! You can set type in the patcher. Or you can set type in expr.Function:

 expr.Function("getValue", func(params ...any) (any, error) {
 	return params[0].(Value).Int, nil
 },
+new(func (Value) int)),

Do you have an example of how to set the type in the patcher instead of the expr.Function?

@antonmedv
Copy link
Member

Sure, try:

node.SetType()

@rrb3942
Copy link
Contributor Author

rrb3942 commented Nov 15, 2023

Sure, try:

node.SetType()

That's what I was trying to use, but it doesn't seem to help catch type mismatches at compile time. I just assume I'm doing something wrong.

https://go.dev/play/p/hori9Xtni-k

Panic happens at Run and not Compile

I also tried using a type of new(func (Value) int)).

@antonmedv
Copy link
Member

I see. This is definitely strange. I will expect what setting type in Patch will take effect here.

Created a issue to track this bug.

@antonmedv
Copy link
Member

Fixed.

Correct code:

package set_type_test

import (
	"reflect"
	"testing"

	"github.com/stretchr/testify/require"

	"github.com/antonmedv/expr"
	"github.com/antonmedv/expr/ast"
)

func TestPatch_SetType(t *testing.T) {
	_, err := expr.Compile(
		`Value + "string"`,
		expr.Env(Env{}),
		expr.Function(
			"getValue",
			func(params ...any) (any, error) {
				return params[0].(Value).Int, nil
			},
			// We can set function type right here,
			// but we want to check what SetType in
			// getValuePatcher will take an effect.
		),
		expr.Patch(getValuePatcher{}),
	)
	require.Error(t, err)
}

type Value struct {
	Int int
}

type Env struct {
	Value Value
}

var valueType = reflect.TypeOf((*Value)(nil)).Elem()

type getValuePatcher struct{}

func (getValuePatcher) Visit(node *ast.Node) {
	id, ok := (*node).(*ast.IdentifierNode)
	if !ok {
		return
	}
	if id.Type() == valueType {
		newNode := &ast.CallNode{
			Callee:    &ast.IdentifierNode{Value: "getValue"},
			Arguments: []ast.Node{id},
		}
		newNode.SetType(reflect.TypeOf(0))
		ast.Patch(node, newNode)
	}
}

@rrb3942
Copy link
Contributor Author

rrb3942 commented Nov 18, 2023

If you wouldn't mind having a look at:

rrb3942@0572429

And give feedback if you think the overall approach looks good. Still working on adding more testing and documentation. Wasn't sure where to put it. Maybe just 'expr/patchers/value' instead of 'expr/builtin/patchers/value'? Or something else entirely.

Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants