dfinity / motoko

Simple high-level language for writing Internet Computer canisters
Apache License 2.0
494 stars 98 forks source link

FR: Improve functional programming for Motoko #3321

Open mikhail-turilin-dfinity opened 2 years ago

mikhail-turilin-dfinity commented 2 years ago

Problem

Working with collections is verbose and difficult. For example, I want to filter blog posts and return the ids:

  // BEFORE
  public shared ({caller}) func allPosts() : async [Nat] {
    return Iter.toArray(Iter.map<Post, Nat>(Iter.filter(posts.vals(), func(p1 : Post) : Bool { p1.principal == caller }), func(p : Post) : Nat { p.id}));
  };

The example is difficult to understand, write, and debug.

Solution 1: Operator piping

We can use something like a pipe operator from F# to improve readability with Swift-style anonymous functions:

  // AFTER
  public shared ({caller}) func allPosts() : async [Nat] {
    return posts.vals()
      |> filter { $0.principal == caller }
      |> map { $0.id }
      |> Iter.toArray;
  };

I think this example is much easier to read and understand.

Solution 2: Class-level higher-order functions

If a pipe operator is too much to ask, we can at least make higher-order functions a part of the class for chaining

  // AFTER
  public shared ({caller}) func allPosts() : async [Nat] {
    return posts
      .vals()
      .filter( func(p : Post) : Bool { p.principal == caller } )
      .map( func(p : Post) { p.id } )
      .toArray();
  };
crusso commented 2 years ago

Yes, something like this would be nice but note that:

1) F# pipelining operators works best with curried functions, and most of the functions in the current base lib are not curried. Moreover, declaring curried functions in Motoko is currently awkward and would require more sugar.

2) Literally adding methods to, e.g. the Iter interface bloats the representation of these objects, which, in the current design of Motoko, are literally records of fields (some of which are functions/methods). That's a very different representation from objects in C# and Java, which just carry the fields of the object, but share all method implementations via a method table pointer (so that adding a method leaves the object representation unchanged).

This FR is related to https://github.com/dfinity/motoko/issues/2537, which proposes adding something akin to C# extension methods (a.k.a F# augmentations) to let you invoke a static function using the dot notation, passing the receiver as the first argument.

crusso commented 2 years ago

It's a little easier to understand your original code if you break it up using a let.

  // Refactor using `let` and explicit type arguments and untyped lambdas
  public shared ({caller}) func allPostsLet1() : async [Nat] {
    let vals = posts.vals();
    let filtered = Iter.filter<Post>(vals, func p { p.principal == caller});
    let ids = Iter.map<Post, Nat>(filtered, func p { p.id });
    Iter.toArray(ids)
  };

  // Refactor using `let` and implicit type arguments, but typed lambdas
  public shared ({caller}) func allPostsLet2() : async [Nat] {
    let vals = posts.vals();
    let filtered = Iter.filter(vals, func (p : Post)  : Bool { p.principal == caller});
    let ids = Iter.map(filtered, func (p : Post)  : Nat { p.id });
    Iter.toArray(ids)
  };

This is actually more efficient than using any higher-order combinator (in the current implementation) since we don't inline those yet and the all the calls will wind up being direct calls.

But yes, having to name the intermediate results is less concise.

https://m7sm4-2iaaa-aaaab-qabra-cai.raw.ic0.app/?tag=3719590015

ggreif commented 2 years ago

Many func p { p.principal == caller}-style lambdas can be simplified by func p = p.principal == caller-style.

aterga commented 2 years ago

To throw in my two rappens:

could we add a syntax for anonymous function arguments, like in Scala? E.g., I would love to be able to write

_.principal == caller

instead of

func p = p.principal == caller

when the type of the lambda-expression can be inferred unambiguously.