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

Find a consistent approach to map Morphir SDK functions to valid Scala #61

Open
AttilaMihaly opened this issue Sep 21, 2020 · 7 comments
Labels
enhancement New feature or request question Further information is requested

Comments

@AttilaMihaly
Copy link
Member

There are a number of differences in the semantics of Morphir compared to Scala which makes it challenging to come up with a consistent mapping. Let's start with a simple SDK function of List.map:

map : (a -> b) -> List a -> List b

The direct Scala mapping of this would be:

def map[A, B](f: A => B)(l: List[A]): List[B]

There are multiple things here that are not idiomatic. Here is how the idiomatic version would look like:

class List[A] {
  def map[B](f: A => B): List[B]
}

There are a number of differences between the approaches:

  • Since Scala comes from an OOP background it prefers using methods to implement functionality that belongs to a specific type so this function would normally be a method on the type itself.
  • Since Morphir is pure FP all functions are curried by default. In Scala this can be done but looks unnatural and makes type-inference more difficult.
  • Ordering of arguments is the opposite of what would be natural in Scala. In Scala the last argument would be the mapping function f. This makes type-inferencing difficult because it's generally applied left-to-right.

We can generalize the mapping approach to the following. Given this Morphir model:

type T = ...

fun : Arg1 -> Arg2 -> ...  -> ArgN -> T -> R

The generated Scala should be:

class T {
  def fun(arg1: Arg1, arg2: Arg2, ..., argN: ArgN): R
}

This will work and look natural in most cases but there are edge cases:

map2 : (a -> b -> c) -> List a -> List b -> List c

This function doesn't have a trivial method mapping since the type appears multiple times in the argument list. It seems that a better approach here is to use a static method/function but still avoid currying and put the lists into the front:

def map2[A, B, C](la: List[A], lb: List[B], f: (A,B)=> C): List[C]

This is more difficult to formalize.

@AttilaMihaly AttilaMihaly added enhancement New feature or request question Further information is requested labels Sep 21, 2020
@DamianReeves
Copy link
Member

DamianReeves commented Sep 21, 2020

So a few things.

  1. I think we should start formalizing a set of rules. It seems there is not a one size fits all approach but maybe we can use certain patterns to guide what we generate.

  2. I think it might also make sense to generate 2 varieties of functions for a single Elm function definition (but this is also rule based).

Maybe if we come up with some rules (or laws) we can "by hand" work through what the resulting code would look like.

Let's take the above examples:

One rule is the OO mapping rule:

Description: OO mapping occurs when the final type of a function matches the type of a type alias or type declared within the same module (perhaps we'll have to revisit this restriction later, but lets go with that until we have a counter example).

type T = ...

fun : Arg1 -> Arg2 -> ...  -> ArgN -> T -> R

So this leads to this:

class List[A] {
  def map[B](f: A => B): List[B]
}

When we move on to the map2 example we find that we need to expand our ruleset:

map2 : (a -> b -> c) -> List a -> List b -> List c

If we look at this there is actually a law consistent with map here.

If the first parameter is a genericized lambda then the generated method must be curried and move the first argument to the last to satisfy inference rules.

class List[A] {
   def map2[A,B,C](listA:List[A])(listB:List[B])(f: (A,B) => C): List[C]
}

Now it, just so happens that this same refactoring works for map (which is just a case where we have no additional inputs), and it should hold for map3 and map4.

While a bit tedious, if we go through elm/core module by module we can write down all the rules and see if we need to modify assumptions or rule selection criteria.

Then and only then do we start writing code.

@DamianReeves
Copy link
Member

So List.singleton shows that the OO rule can't apply here:

This leads to the Constructor/Factory Rule.

A function of the form:

type TypeA
foo: a -> TypeA

Is considered a constructor or factory and as such will be created as a function on the companion object:

object TypeA {
   def foo[A](arg1:A):TypeA
}

@DamianReeves
Copy link
Member

By the way: My gut feeling has been for a while that we should output both the OO and static version of functions in every case.

@AttilaMihaly
Copy link
Member Author

This example doesn't look right:

class List[A] {
   def map2[A,B,C](listA:List[A])(listB:List[B])(f: (A,B) => C): List[C]
}

Since you have both input lists as an argument putting it on the class is confusing. Or did you mean this?

class List[A] {
   def map2[A,B,C](listB:List[B])(f: (A,B) => C): List[C]
}

To me this is a fundamental limitation in OOP (and in every entity based approach, relational and semantic web too). They assume that there is always a single entity/object you are working with which is not the case with mapN functions (and a lot of others). FP (and even structural programming) has an edge here since it doesn't attach functions to entities so it's more generic. I wrote a blog post about the topic of triples not being able to represent logic/data efficiently some time back: https://ozmi.wordpress.com/2015/01/17/a-unified-model-for-data-and-logic/

Anyway, I digressed. Main thing is I think the OO can only apply if there is exactly one argument (should probably limit it to the last) with a type in the same module. For the rest we should simply not apply the OO rule.

@AttilaMihaly
Copy link
Member Author

@DamianReeves
Copy link
Member

This example doesn't look right:

class List[A] {
   def map2[A,B,C](listA:List[A])(listB:List[B])(f: (A,B) => C): List[C]
}

Since you have both input lists as an argument putting it on the class is confusing. Or did you mean this?

class List[A] {
   def map2[A,B,C](listB:List[B])(f: (A,B) => C): List[C]
}

To me this is a fundamental limitation in OOP (and in every entity based approach, relational and semantic web too). They assume that there is always a single entity/object you are working with which is not the case with mapN functions (and a lot of others). FP (and even structural programming) has an edge here since it doesn't attach functions to entities so it's more generic. I wrote a blog post about the topic of triples not being able to represent logic/data efficiently some time back: https://ozmi.wordpress.com/2015/01/17/a-unified-model-for-data-and-logic/

Anyway, I digressed. Main thing is I think the OO can only apply if there is exactly one argument (should probably limit it to the last) with a type in the same module. For the rest we should simply not apply the OO rule.

You are right, what I wanted to write was:

class List[A] {
   def map2[A,B,C](listB:List[B])(f: (B,A) => C): List[C]
}

Now this looks a bit strange, because the type parameters are renamed.

Also I do agree that the functional encoding of this is easier.

@AttilaMihaly
Copy link
Member Author

So what's the conclusion here? As I'm reading through this again I'm reminded that all this rearranging will have a significant impact on the call-site especially if the functions are partially applied. It will get very difficult if we change the ordering.

Maybe it would be better to just map everything directly as we do now and focus on adding type information where needed?

DamianReeves pushed a commit that referenced this issue May 25, 2022
* adding ValueModuleSpec
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request question Further information is requested
Projects
None yet
Development

No branches or pull requests

2 participants