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

Support cloning functions #4281

Open
rrousselGit opened this issue Mar 3, 2025 · 8 comments
Open

Support cloning functions #4281

rrousselGit opened this issue Mar 3, 2025 · 8 comments
Labels
feature Proposed language feature that solves one or more problems

Comments

@rrousselGit
Copy link

One common programing pattern is to have a reusable utility which wraps a function to perform logic before/after and to transform the result.

A typical example is a logger middleware. One might write:

T Function() logged<T>(T Function() cb) {
  return () {
    print('Before');
    final value = cb();
    print('After $value');
    return value;
  };
}

Then used as:

class Example {
  late final method = logged(() {
    return 42;
  }); 
}

The problem is:
Our logged util is bound to the T Function() prototype. It does not accept any other Function variants.
If we want a Example.method which takes parameters, we have to write a new logged ; such as loggedUnary / loggedBinary / ...

This is a common issue. For example we have Zone.current.bindCallback vs Zone.current.bindUnaryCallback vs Zone.current.bindBinaryCallback ...

Proposal: Make all Functions have a compose method, and add a TypedFunction<Return, Arg> interface

The idea is that all functions would implement a new interface defined as:

// A new primitive, similar to Invocation but typed.
abstract class ArgumentList {}

abstract class TypedFunction<ReturnT, ArgsT extends ArgumentList> {
  TypedFunction<NewReturnT, ArgsT> compose<NewReturnT>(
    NewReturnT Function(
      ReturnT Function(ArgsT args),
      ArgT args,
    ) cb,
  );

 // Not variadic arguments. I'm just using ... because I don't know what other syntax would fit
  ReturnT call(...ArgsT args);
}

Consider:

String nullary() => 0;
int unary(int a) => a;
int complex(int a, {required String b, double c = 42, bool? d}) => 42;

Those functions would implement TypedFunction as followed:

TypedFunction<String, _()> a = nullary;
TypedFunction<int, _(int)> b = unary;
TypedFunction<int, _(int, {required String b, double c = 42, bool? d})> b = complex;

Note:
The second generic type in this snippet isn't a Record! It's a new type that represents a subclass of ArgumentList.

Based on those interfaces, we can do:

final complex2 = complex.compose((call, args) {
  print('Before $args');
  final result = call(args);
  print('After $result');
  return result;
})

complex2(1, b: 'b', c: 2, d: false);

This would log:

Before _(1, b: 'b', c: 2, d: false)
After 42

Example 1: Logging

Using this new feature, we could implement an all purpose logged utility:

TypedFunction<ResT, ArgsT> logged<ResT, ArgsT extends ArgumentList>(
  TypedFunction<RestT, ArgsT> cb,
) {
  return cb.compose((call, args) {
    print('Before $args');
    final result = call(args);
    print('After $result');
    return result;
  });
}

This could then be used as

class Example {
  late final method = logged(() {
    return 42;
  });

  late final method2 = logged((int arg, {required String value}) {
    ...
  }
}

void main() {
  final example = Example();
  example.method();
  example.method2(1, value: 'value');
}

Example 2: Wrapping functions in a Result<T>

Consider:

sealed class Result<T> {}
class ResultData<T> implements Result<T> {}
class ResultError<T> implements Result<T> {}

We could implement a generic guard utility:

TypedFunction<Result<T>, ArgsT> guard<T, ArgsT extends ArgumentList>(
  TypedFunction<T, ArgsT> cb,
) {
  return cb.compose((call, args) {
    try {
      final result = call(args);
      return ResultData(result);
    } catch (err) {
      return ResultError(result);
    }
  });
}

This could then be used as

class Example {
  late final method = guard(() {
    return 42;
  });

  late final method2 = guard((int arg, {required String value}) {
    return 42;
  }
}

void main() {
  final example = Example();
  Result<int> a = example.method();
  Result<int> b = example.method2(1, value: 'value');
}
@rrousselGit rrousselGit added the feature Proposed language feature that solves one or more problems label Mar 3, 2025
@rrousselGit
Copy link
Author

rrousselGit commented Mar 3, 2025

We probably could also look into a better syntax than late final field = decorator(...). Like:

class Example {
  #guard
  #logged
  int method(String value) => 42;
}

But that's a bit out of the scope of this issue.

@lrhn
Copy link
Member

lrhn commented Mar 3, 2025

The biggest immediate issue is the typing.

// A new primitive, similar to Invocation but typed.
abstract class ArgumentList {}

abstract class TypedFunction<ReturnT, ArgsT extends ArgumentList> {

The "similar to Invocation, but typed" skips all the hard parts. How is it typed? What is it even?

Is there a whole type hierarchy similar to record types, but with each parameter also being able to be optional?
And either contravariant (so (int, int, {required int x}) is a supertype of (num, int, {int x})) or not, but if not then TypedFunction must be contravariant in the second type parameter.

If you want to abstract over parameter lists using generics, then parameter lists need to be types.
(It's not argument lists, it's parameter lists, which is why the optional/required is needed.)

If Dart didn't have optional parameters, it would be much easier. The parameter list type would just be a record type, and every function is a unary function that takes a single record value (and then the parameter list implicitly pattern matches it).
But it ain't so. There is no simple record type which represents the parameter list of void Function([int]).

I think it would be much more likely to introduce a "function wrapper" function where you never get access to the parameters, and therefore never need to know their "type".

/// Creates a function of with type `F`, which must be a function type (not `Function` or `Never`).
///
/// When the returned function is invoked with an argument list, the [body] is invoked with a function
/// which must be invoked. When that function is invoked with a function argument,
/// that function is invoked with the same argument list as the returned function, and whatever 
/// value it returns is memorized as the result of the returned function invocation.
/// If that result is a future, then the invocation returns a future which won't complete until
/// the result future has. Otherwise it returns `null`.
F functionProxy<F extends Function>(void Function(Future<void>? Function(F)) body);

Example use:

F logBeforeAfter<F extends Function>(String before, String after, F function) =>
  functionProxy<F>((invoke) async {
    print(before);
    try {
      if (invoke(function) case var delay?) await delay;
    } finally {
      print(after);
    }
  });

Async just makes things extra complicated!

It would be a little easier to work with if you could just abstract over the return type of a function.
It's easier than parameter lists. (I'm certain there is an issue about that.)

@mraleph
Copy link
Member

mraleph commented Mar 3, 2025

#4271 can be extended support this use case:

F wrap<@konst F extends Function>(F cb, void Function() before, void Function() after) {
  final typeInfo = TypeInfo.of<F>();
  return fun((positional, named, types) {
    before();
    final result = invoke(cb, positional, named: named, types: types); 
    after();
    return result;
  });
}

An extension to what is already contained in #4271 is a primitive to construct functions:

/// [R] is a return type of [F].
F fun<@konst F extends Function>(@konst Object? Function(List positional, Map<String, Object?> named, List<Type> types) handler)
```. 

Though there are some challenges around achieving very precise typing because function type can't be deconstructed.

@rrousselGit
Copy link
Author

rrousselGit commented Mar 3, 2025

// A new primitive, similar to Invocation but typed.
abstract class ArgumentList {}

abstract class TypedFunction<ReturnT, ArgsT extends ArgumentList> {

The "similar to Invocation, but typed" skips all the hard parts. How is it typed? What is it even?

I covered it in later snippets:

Consider:

String nullary() => 0;
int unary(int a) => a;
int complex(int a, {required String b, double c = 42, bool? d}) => 42;

Those functions would implement TypedFunction as followed:

TypedFunction<String, _()> a = nullary;
TypedFunction<int, _(int)> b = unary;
TypedFunction<int, _(int, {required String b, double c = 42, bool? d})> b = complex;

I purposefully used _(...) here because (...) is taken by records and we can't use records. I also mentioned that we're not using records :)


Basically ArgumentList would be just the argument part of Function.

The idea is, given void Function([int a]) we have <ReturnValue> Function<ArgumentList>.
Writing _([int a] refers to * Function([int a]), but instead of a function, we just get the argument bits.

It'd behave like how Function subclasses typically behave. So we could do:

_([int a]) optionalArg; 
_() emptyArgList = optionalArg;

I don't think we'd have a literal for manually creating _() instances though ; and would likely instead obtain them from SDK functions.

@lrhn
Copy link
Member

lrhn commented Mar 3, 2025

Ack. So it is a complete type hierarchy which is more complicated than records, but which contains a subset that is similar to records.

The types are the type of function parameter lists, not argument lists (using the traditional naming).
The corresponding values are the argument lists, which have no optional arguments.

An argument list value is assignable to a number of parameter list types.
The most precise one, and the runtime type of an argument list, is the parameter list with no optional parameters, and each parameter having the same type as the runtime type of the argument. Which is basically a record type.
(So we could make record types be the argument list types, and parameter types a superset of those that also contains types with optional parameters.)

But if parameter list types are the types of argument lists, then they are covariant. It will be the TypedFunction<out R, in P> which introduces the contravariance.

That does mean that:

_([int a]) optionalArg = ...
_() emptyArgList = optionalArg;

would be a compile-time error.
The value of optionalArg will be an argument list with zero or one positional argument.
That can't be assigned to emptyArgList which does not allow one positional argument.

So something like:

abstract final class ParameterList {}
abstract final class Record extends ParameterList {}

and then ({int x, int y}) is a Record type and a ParameterList type,
and _({required int x, required int y}) is the same type, but _({int x, int y}) is a supertype of that type which is a ParameterList type, but not a Record type. There is no value which is a ParameterList, but not a Record. The parameter lists are basically union types of Record types.

Then external typedef TypedFunction<out R, in P extends ParameterList>; is a magical type declaration that introduces a Function type, and which we can use to destructure function types.

Or we could allow type spreads and write it as R Function(...P).

R Function(P) logAround<R, P extends ParameterList>(String before, String after, R Function(...P) function) => (...P args) {
  print(before);
  try {
    return function(...args);
  } finally {
    print(after);
  }
};

@tatumizer
Copy link

There's a parallel thread #3444 which, upon some slight generalization in this comment, addresses the same problem with a much simpler solution.
In the discussion below the comment, @eernstg proposes different syntactic variants, but the meaning is still the same.

@ykmnkmi
Copy link

ykmnkmi commented Mar 4, 2025

Can this ignore arguments if I call a different function that accepts fewer arguments?

@eernstg
Copy link
Member

eernstg commented Mar 4, 2025

I think there's a very interesting design space here, about the creation of functions that are based on existing functions. An overall description of the situation is that we wish to create a wrapper function based on a given wrapped function, aka the wrappee.

We might want to change the actual arguments in some way, or we might want to modify the returned value, or we might want to perform some actions before and/or after the invocation of the wrapped function, or we might even want to decide whether the wrapped function should be invoked at all, or whether it should be invoked repeatedly, perhaps bounded by some condition. That is, we basically want to be able to write general code.

However, we also need to abstract away from the formal parameter declarations and the actual argument lists, because we'd like to make this wrapping setup work for many different function types.

When it comes to the forwarding invocation itself, we presumably need a semantics which will ensure that the wrappee is called "in the same way as the wrapper was called" (except when the invocation is explicitly modified, e.g., by passing other actual arguments for some of the parameters). The standard example to show that this matters is the following:

abstract class A {
  int foo([int i]);
}

class B1 extends A {
  int foo([int i = 1]) => i;
}

class B2 extends A {
  int foo([int i = 2]) => i;
}

class C {
  A a;
  C(this.a);
  foo ==> a; // This means that `foo` forwards to `a.foo`.
}

void main() {
  var c1 = C(B1()), c2 = C(B2());
  print('Should be 1: ${c1.foo()}, should be 2: ${c2.foo()}');
}

Naively, we could try to write the forwarding declaration foo ==> a; as int foo([int i = k]) => a.foo(i); where k is some constant int. However, this declaration will not do the right thing no matter which value we choose for k.

That's the reason why I'm arguing in #3444 that we need a new primitive which will support forwarding invocations where the default values are preserved correctly.

If we have support for faithful forwarding invocations then we can start generalizing the mechanism such that we can introduce all those modifications of the invocation (changing the values of some actual arguments rather than just forwarding all of them, choosing whether or not to call the wrappee at all, etc).

Another dimension that we should keep in mind is the distinction between run-time operations and compile-time operations.

For example, @mraleph's approach is quite powerful and convenient, and it might be possible to generalize it such that it can express true forwarding invocations (otherwise that would still need to be a new primitive, which could then be used by this kind of meta-programming declarations). This is an approach that relies entirely on compile-time information.

Another (very different) approach could be to support a primitive F Function.wrap<F extends Function>(F fun) which returns a function object whose signature is identical to the signature of the given actual argument, and whose semantics is to call fun in a true forwarding fashion. This function object could then have additional methods like addBefore(void Function()) and addAfter(void Function()), and perhaps some kind of actual argument manipulation (a function that receives a record containing the actual arguments and returns another record containing the modified actual argument list, with some suitable treatment of presence/absence of arguments for each of the optional parameters). This is an approach that relies entirely on run-time information.

Obviously, the compile-time mechanism can be checked statically. However, the run-time mechanism can also be checked statically in that it is known that Function.wrap returns a function whose signature is exactly the same as the signature of the actual argument, which means that all invocations of the returned function object can be statically type safe as long as the type of the actual argument is a function type (that is, not Function, not Object, and not a top type like dynamic or Object?).

In any case, this is indeed a very interesting topic and I'm sure we can come up with lots of good ideas. ;-)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Proposed language feature that solves one or more problems
Projects
None yet
Development

No branches or pull requests

6 participants