In functional programming, it is typical to write patterns for calling functions, instead of just calling them. Example: List.map
map : (a -> b) -> List a -> List b
Apply a function to every element of a list.
map sqrt [1,4,9] == [1,2,3] map not [True,False,True] == [False,True,False]
Here, instead of [not True,not False,not True]
or [sqrt 1,sqrt 4,sqrt 9]
, we use map
to reuse the pattern of calling a function on each element of a list and putting the result in the new list. The key technique here is that we get to choose to use not
or sqrt
or squareThenSubtractOneThenDivideByOneGreaterThanTheOriginalNumberThenAddOne
or what have you. map
wasn’t written to act on data directly but rather to shuffle numbers around in the right way according to some often-enough-used pattern. We decouple the generic use-case from the operation. This technique is really fascinating to functional programming nerds such as m’self, because what is a really painful and stupid process in OOP is as easy and simple as adding another parameter in FP. This is one of the many reasons why I love Elm and think it’s worth sharing!
Of course, there’s no reason to stop at map
, we also have the functions List.foldl
, List.sortWith
, Dict.merge
, Maybe.andThen
, the List
goes on (see what I did there?). The way to spot them is to find a function that takes at least these parameters:
- A function, and
- Some data you could call that function with
Notice something interesting. List.map
takes one decoupled function, but Dict.merge
takes three! How many can you have? Probably quite a few, but there’s no reason to have a tediously long type signature for data you reuse often enough to warrant the abstraction. Instead, we can use a record, which gives us the benefit of having named fields. For example, let’s say we have a Vector
module:
-- In the Vector module
type Vector
= Vector Float Float
add : Vector -> Vector -> Vector
add = . . .
negate : Vector -> Vector
negate = . . .
normalize : Vector -> Vector
normalize = . . .
-- In some other module where you use the Vector module
type alias VectorOps =
{ add : Vector -> Vector -> Vector
, negate : Vector -> Vector
, normalize : Vector -> Vector
}
vector : VectorOps
vector =
{ add = Vector.add
, negate = Vector.negate
, normalize = Vector.normalize
}
So here I have three ops, add
, negate
, and normalize
, but you can imagine that for some complex forms you could have save
, send
, validate
, review
, share
, autofill
, and whatever else your heart beckons for.
This is pretty powerful on its own; now you can use the Vector
module’s datastructure in patterns that take any combination of these vector ops. Consider Addable
:
type alias Addable ops datastructure =
{ ops
| add : datastructure -> datastructure -> datastructure
}
doThingsToSomethingYouCanAdd : Addable ops ds -> ds -> ds -> otherStuff -> etc -> . . .
. . .
By calling doThingsToSomethingYouCanAdd vector myVectorA myVectorB otherstuff . . .
, you have decoupled a function that adds data. Congratulations! This function does “something” with a datatype where it makes sense to add them together (as opposed to a datatype where it doesn’t, such as VectorAndScalar
, or Fruit
, i.e. what is Apple + Orange?). Fill in “something” with “sum a list” or “muliply” or whatever. Just as multiplication follows from addition, subtraction follows from having both add
and negate
. I’ll leave it to the reader to come up with how to write AddableAndNegateable ops ds
–let’s move on.
So this is the clincher: you need to fill in an ...Ops
-type record, like VectorOps
, PerlinNoiseMapOps
, MassInNonEuclideanSpacetimeOps
, FruitThatCanSomehowBeNegatedOps
, and so on for every datatype module that is Addable
or AddableAndNegatable
or NegateableAndNormalizeable
, etc. And because you’ve chosen to abstract it this far, there are probably quite a few such modules. This means doing this pattern again and again:
vector : VectorOps
vector =
{ add = Vector.add
, negate = Vector.negate
, normalize = Vector.normalize
}
perlinNoise : PerlinNoiseMapOps
perlinNoise =
{ negate = PerlinNoise.negate
, normalize = PerlinNoise.normalize
}
sillyMass : MassInNonEuclideanSpacetimeOps
sillyMass =
{ add = MassInNonEuclideanSpacetime.add
, normalize = MassInNonEuclideanSpacetime.normalize
}
fruit : FruitThatCanSomehowBeNegatedOps
fruit =
{ negate = Fruit.negateSomehowIdk
}
This is tedious. Unfortunately, no pattern can solve this because it is a technique leveraging Elm’s type system rather than performing some kind of computation. Finally, 5031 characters in, I get to my proposal: Elm could benefit from having syntactic sugar that writes these records for the user based on the module definition instead of them having to repetitiously do this manually. This makes writing Elm a more pleasant experience for this user.
To specify, say we have this module, likely centered around a datastructure:
module MyModule exposing (..)
. . .
functionA : a -> b -> c
. . .
functionB : d -> e -> f
. . .
The only functions in I propose some shortened syntax to write this:MyModule
are functionA
and functionB
.
mymodule =
{ functionA = MyModule.functionA
, functionB = MyModule.functionB
}
adapted accordingly to however many functions MyModule
has. In this way, we use a module like a record to solve the problem of many large, alike, decoupled datastructures. I believe @evancz and his team is capable of coming up with a nice design for this syntax, but just for the sake of being concrete I’ll show what I have in mind.
At the module import, there is a new keyword, fitting
, that works quite like exposing
.
import MyModule as M
fitting
( mymodule :
{ functionA : a -> b -> c
, functionB : d -> e -> f
}
)
exposing (foo, bar, baz, quux)
mymodule
’s properties are “fitted to”MyModule.functionA
andMyModule.functionB
if they’re exposed fromMyModule
- The type signature here is optional, but recommended: we wouldn’t want the creator of
MyModule
to go and change its names and types on us without us knowing! - I don’t expect this to be used for many exposed modules, just internal ones. If any exposed modules do this, they probably aim for extensibility
- As of yet, I don’t know of a practical use for the cases below. I hope commenters will tell me what they think of them!
- Fitting a type that isn’t a record, like a
Float
or a union type. Though, I figure an extensible record could be useful - Fitting multiple type declarations, with
mymoduleA : { . . . }
,mymoduleB : { . . . }
, etc.
- Fitting a type that isn’t a record, like a
================================================================================
Footnote:
I get that Elm’s general policy is to be meticulous when adding features because adding features adds more stuff new users need to learn to use Elm. Here, @luke has said that he evaluates features like so:
To what greater degree does the feature enable an Elm user to achieve the goals Elm proposes to help with beyond what is already possible?
In this interview @evancz has stated Elm’s goal to “provide a pleasant programming experience […] how can we make it possible to do that and for you to have fun doing it?” I argue that this feature, while putting hardly any new mental burden on users, still promotes the fun, expressive, and collaborative tooling Elm aims for because of the following reasons:
- The parallel between records and modules is already demonstrated in the syntax by the namespacing (.) operator
- The reduced redundancy allows for datatypes’ features to be implemented in linkable pieces like Lego bricks instead of case-by-case, making the code more concise and reducing naming inconsistencies
- Modules-as-records reduces gratuitous abstraction and focuses on the reuse that actually matters via the option to enforce the type signature
================================================================================
Thanks for reading! Let me know what you think of this idea.
anon5324703