Skip to content

Feat: Implement Lambda Functions and Callable Interface #39

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

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 63 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

[:heart: sponsor](https://github.com/sponsors/rbellens)


Expand All @@ -19,45 +18,82 @@ It is partly inspired by [jsep](http://jsep.from.so/).

Example 1: evaluate expression with default evaluator

// Parse expression:
Expression expression = Expression.parse("cos(x)*cos(x)+sin(x)*sin(x)==1");

// Create context containing all the variables and functions used in the expression
var context = {
"x": pi / 5,
"cos": cos,
"sin": sin
};
```dart
// Parse expression:
Expression expression = Expression.parse("cos(x)*cos(x)+sin(x)*sin(x)==1");

// Evaluate expression
final evaluator = const ExpressionEvaluator();
var r = evaluator.eval(expression, context);
// Create context containing all the variables and functions used in the expression
var context = {
"x": pi / 5,
"cos": cos,
"sin": sin
};

// Evaluate expression
final evaluator = const ExpressionEvaluator();
var r = evaluator.eval(expression, context);

print(r); // = true

print(r); // = true
```


Example 2: evaluate expression with custom evaluator

// Parse expression:
Expression expression = Expression.parse("'Hello '+person.name");
```dart
// Parse expression:
Expression expression = Expression.parse("'Hello '+person.name");

// Create context containing all the variables and functions used in the expression
var context = {
"person": new Person("Jane")
};

// The default evaluator can not handle member expressions like `person.name`.
// When you want to use these kind of expressions, you'll need to create a
// custom evaluator that implements the `evalMemberExpression` to get property
// values of an object (e.g. with `dart:mirrors` or some other strategy).
final evaluator = const MyEvaluator();
var r = evaluator.eval(expression, context);


print(r); // = 'Hello Jane'
```


Example 3: evaluate expression with lambdas

```dart
// Expressions can also include lambdas. These are handled by creating a
// `MemberAccessor` that returns a `Callable` object.

class WhereCallable extends Callable {
final List<dynamic> list;
WhereCallable(this.list);

// Create context containing all the variables and functions used in the expression
var context = {
"person": new Person("Jane")
};
@override
dynamic call(ExpressionEvaluator evaluator, List<dynamic> args) {
var predicate = args[0] as Callable;
return list.where((e) => predicate.call(evaluator, [e]) as bool).toList();
}
}

// The default evaluator can not handle member expressions like `person.name`.
// When you want to use these kind of expressions, you'll need to create a
// custom evaluator that implements the `evalMemberExpression` to get property
// values of an object (e.g. with `dart:mirrors` or some other strategy).
final evaluator = const MyEvaluator();
var r = evaluator.eval(expression, context);
// Parse expression with a lambda:
var expression = Expression.parse('[1,9,2,5,3,2].where((e) => e > 2)');

// Create an evaluator with a member accessor for `List.where`.
// The accessor for 'where' returns our custom `WhereCallable` object.
final evaluator = ExpressionEvaluator(memberAccessors: [
MemberAccessor<List>({
'where': (list) => WhereCallable(list as List),
}),
]);

print(r); // = 'Hello Jane'
// Evaluate expression:
var r = evaluator.eval(expression, {});

print(r); // = [9, 5, 3]
```


## Features and bugs
Expand Down
49 changes: 49 additions & 0 deletions lib/src/evaluator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ class ExpressionEvaluator {
if (expression is ConditionalExpression) {
return evalConditionalExpression(expression, context);
}
if (expression is LambdaExpression) {
return evalLambdaExpression(expression, context);
}
throw ArgumentError("Unknown expression type '${expression.runtimeType}'");
}

Expand Down Expand Up @@ -117,6 +120,9 @@ class ExpressionEvaluator {
CallExpression expression, Map<String, dynamic> context) {
var callee = eval(expression.callee, context);
var arguments = expression.arguments.map((e) => eval(e, context)).toList();
if (callee is Callable) {
return callee.call(this, arguments);
}
return Function.apply(callee, arguments);
}

Expand Down Expand Up @@ -197,6 +203,12 @@ class ExpressionEvaluator {
: eval(expression.alternate, context);
}

@protected
dynamic evalLambdaExpression(
LambdaExpression expression, Map<String, dynamic> context) {
return _Lambda(expression, context);
}

@protected
dynamic getMember(dynamic obj, String member) {
for (var a in memberAccessors) {
Expand Down Expand Up @@ -278,3 +290,40 @@ class _MemberAccessor<T> implements MemberAccessor<T> {
return accessors[member]!(object);
}
}

abstract class Callable {
const Callable();

dynamic call(ExpressionEvaluator evaluator, List args);
}

class _Lambda implements Callable {
final LambdaExpression expression;

/// The context in which the lambda was defined (lexical scope).
///
/// A lambda represents a closure, which captures the context where it was
/// defined. When the lambda is called, it uses this definition-site context
/// to resolve variables, which is how closures work in Dart and most modern
/// languages.
final Map<String, dynamic> context;

const _Lambda(this.expression, this.context);

@override
dynamic call(ExpressionEvaluator evaluator, List args) {
var params = expression.params;
if (params.length != args.length) {
throw ArgumentError(
'Lambda expected ${params.length} arguments, but got ${args.length}.');
}

var localContext = Map<String, dynamic>.from(context);

for (var i = 0; i < params.length; i++) {
localContext[params[i].name] = args[i];
}

return evaluator.eval(expression.body, localContext);
}
}
10 changes: 10 additions & 0 deletions lib/src/expressions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ class CallExpression extends SimpleExpression {
String toString() => '${callee.toTokenString()}(${arguments.join(', ')})';
}

class LambdaExpression extends SimpleExpression {
final List<Identifier> params;
final Expression body;

LambdaExpression(this.params, this.body);

@override
String toString() => '(${params.join(', ')}) => $body';
}

class UnaryExpression extends SimpleExpression {
final String operator;

Expand Down
34 changes: 27 additions & 7 deletions lib/src/parser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class ExpressionParser {
(l) => l[1] == null
? l[0]
: ConditionalExpression(l[0], l[1][0], l[1][1])));
token.set((literal | unaryExpression | variable).cast<Expression>());
token.set((unaryExpression | variable).cast<Expression>());
}

// Gobbles only identifiers
Expand Down Expand Up @@ -99,6 +99,14 @@ class ExpressionParser {
mapLiteral)
.cast();

Parser<Expression> get _primary =>
(literal |
lambdaExpression |
group |
thisExpression |
identifier.map((v) => Variable(v)))
.cast();

// An individual part of a binary expression:
// e.g. `foo.bar(baz)`, `1`, `'abc'`, `(a % 2)` (because it's in parenthesis)
final SettableParser<Expression> token = undefined<Expression>();
Expand Down Expand Up @@ -203,13 +211,28 @@ class ExpressionParser {
.map((l) => Map.fromEntries(l))
.optionalWith({});

Parser<List<Identifier>> get lambdaParameters =>
(char('(').trim() &
identifier
.plusSeparated(char(','.trim()))
.map((p) => p.elements)
.castList<Identifier>()
.optionalWith([]) &
char(')').trim())
.pick(1)
.cast<List<Identifier>>();

Parser<LambdaExpression> get lambdaExpression =>
(lambdaParameters.trim().seq(string('=>').trim()).seq(expression))
.map((l) =>
LambdaExpression(l[0] as List<Identifier>, l[2] as Expression));

// Gobble a non-literal variable name. This variable name may include properties
// e.g. `foo`, `bar.baz`, `foo['bar'].baz`
// It also gobbles function calls:
// e.g. `Math.acos(obj.angle)`
Parser<Expression> get variable => groupOrIdentifier
.seq((memberArgument.cast() | indexArgument | callArgument).star())
.map((l) {
Parser<Expression> get variable =>
_primary.seq((memberArgument.cast() | indexArgument | callArgument).star()).map((l) {
var a = l[0] as Expression;
var b = l[1] as List;
return b.fold(a, (Expression object, argument) {
Expand All @@ -234,9 +257,6 @@ class ExpressionParser {
Parser<Expression> get group =>
(char('(') & expression.trim() & char(')')).pick(1).cast();

Parser<Expression> get groupOrIdentifier =>
(group | thisExpression | identifier.map((v) => Variable(v))).cast();

Parser<Identifier> get memberArgument =>
(char('.') & identifier).pick(1).cast();

Expand Down
68 changes: 68 additions & 0 deletions test/expressions_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,15 @@ void main() {
});

group('member expressions', () {
test('literal members', () {
var evaluator = ExpressionEvaluator(memberAccessors: [
MemberAccessor<List>({'length': (v) => v.length}),
MemberAccessor<String>({'length': (v) => v.length})
]);
expect(evaluator.eval(Expression.parse('[1,2,3].length'), {}), 3);
expect(evaluator.eval(Expression.parse("'hello'.length"), {}), 5);
});

test('toString member', () {
var evaluator = ExpressionEvaluator(memberAccessors: [
MemberAccessor<Object?>({'toString': (v) => v.toString})
Expand Down Expand Up @@ -323,6 +332,27 @@ void main() {
expect(evaluator.eval(Expression.parse('func1( 1 )'), context), 42);
expect(evaluator.eval(Expression.parse('func2( 1, 2 )'), context), 42);
});

test('callable functions', () {
var evaluator = ExpressionEvaluator();
var context = {
'sum': SumCallable(),
};
var expression = Expression.parse('sum(1, 2, 3, 4)');
var result = evaluator.eval(expression, context);
expect(result, 10);
});

test('lambda functions', () {
var evaluator = ExpressionEvaluator(memberAccessors: [
MemberAccessor<List>({
'where': (list) => WhereCallable(list),
}),
]);
var expression = Expression.parse('[1,9,2,5,3,2].where((e) => e > 2)');
var result = evaluator.eval(expression, {});
expect(result, [9, 5, 3]);
});
});
});

Expand All @@ -336,3 +366,41 @@ void main() {
});
});
}

class SumCallable extends Callable {
@override
dynamic call(ExpressionEvaluator evaluator, List args) {
return args.cast<num>().fold<num>(0, (p, e) => p + e);
}
}

class WhereCallable extends Callable {
final List<dynamic> list;

const WhereCallable(this.list);

@override
dynamic call(ExpressionEvaluator evaluator, List<dynamic> args) {
if (args.length != 1) {
throw ArgumentError('where() expects one argument, got ${args.length}');
}
final predicate = args[0];
if (predicate is! Callable) {
throw ArgumentError(
'Argument to where() must be a function, got ${predicate.runtimeType}');
}

final result = <dynamic>[];
for (final element in list) {
final predicateResult = predicate.call(evaluator, [element]);
if (predicateResult is! bool) {
throw ArgumentError(
'Predicate must return a boolean value, but got ${predicateResult.runtimeType}');
}
if (predicateResult) {
result.add(element);
}
}
return result;
}
}