grain icon indicating copy to clipboard operation
grain copied to clipboard

Lang: `|>` reverse application operator

Open ospencer opened this issue 3 years ago • 17 comments

The reverse application operator (|>) (sometimes called the pipeline operator) takes two arguments and applies the second argument to the first argument. The type signature of |> is (a, a -> b) -> b. It is left-to-right associative and of this snapshot of our operator precedence document it would have operator precedence 45.

Here's an example program:

let filterNegatives = partial List.filter(a => a < 0);
let doubleValues = partial List.map(a => a * 2);

[-1, -2, 0, 1, 2]
|> filterNegatives
|> doubleValues

ospencer avatar Oct 22 '20 22:10 ospencer

We could totally just implement this as let (|>) = (a, b) => b(a), but we should probably have an optimization for it if we do that. I love the idea of having this defined as a function, though.

The alternative is that this is just syntactic sugar.

ospencer avatar Oct 22 '20 22:10 ospencer

I would be up for working on this if you're cool with it 😄

bmakuh avatar Nov 17 '20 17:11 bmakuh

I'm actually up to this right now as I'm implementing curry! My bad for not assigning this to myself!

ospencer avatar Nov 17 '20 17:11 ospencer

ah ok, no worries

bmakuh avatar Nov 17 '20 17:11 bmakuh

If this gets implemented we should also address #671 Right now the |> doesn't really fit into the stdlib.

It's not possible to write something like [1, 2, 3] |> List.reverse |> List.map(fn) because map takes the list as second argument and not as the first one. For this operator to actually feel like part of the language it has to be compatible with the stdlib where possible.

Simerax avatar Sep 07 '22 00:09 Simerax

Any progress on adding this to the language together with a data-first approach and maybe curry placeholders? This would really aid a lot in reading and writing Grain code :-)

timjs avatar Jan 11 '23 08:01 timjs

if it is not possible to implement the pipe operator after changes with data-first, would it be possible to implement "Uniform Function Call" like nim?

the above example would look

[-1, -2, 0, 1, 2].filterNegatives().doubleValues()

I don't know if it's possible to implement uniform function call with type inference (like hindley-milner)

tredeneo avatar Jan 25 '23 15:01 tredeneo

@tredeneo The idea of the pipe operator is to allow for the same functionality as "Uniform function calls" so it wouldn't make sense to have both. It also shouldn't be a problem for HM type inference as it's just an alternate way to apply arguments to a function. ReScript has a feature closer to what you're talking about for instance https://rescript-lang.org/docs/manual/latest/pipe

alex-snezhko avatar Jan 25 '23 18:01 alex-snezhko

Currently at 0.5.13, if I have the following definitions:

let add = a => b => a + b;
let (|>) = (a, f) => f(a)

then this syntax works:

let x = 4 |> add (5)

and this syntax works:

let x = 
    4 |>
    add 5

but syntax error is thrown when the operator begins a new line:

let x = 
    4 
    |> add (5)

woojamon avatar Jan 30 '23 14:01 woojamon

Yes, binary operators in Grain must appear on the same line as the first argument.

ospencer avatar Jan 30 '23 18:01 ospencer

Sure, and there's no technical problem with it staying like that. For some binary operators maybe it seems natural to put the operator at the end of the line, but coming from other functional languages I see that its more conventional and natural to put the pipeline operator starting on new line. Even in the example program you shared you started the new lines with the pipeline operator, so I think Grain should support that syntax too.

woojamon avatar Jan 30 '23 18:01 woojamon

Just calling out that it's not a bug 🙂

Supporting that syntax would be an entirely separate issue from this, though. Because an end-of-line character terminates a statement in Grain, it's non-trivial to support, and probably something we won't be able to support until a full parser rewrite.

ospencer avatar Jan 30 '23 19:01 ospencer

Cool! I certainly think it would be a boon for Grain, just one less thing for newcomers like me from other functional languages to get quirky over.

I've only gotten started with Grain in the past few days and love it so far. I appreciate all your hard work and hopefully can contribute someday when I get more comfortable with parsers and lexers and such.

woojamon avatar Jan 30 '23 19:01 woojamon

I will say though, if it doesn't become baked into the language, I still do like the ability to define |> however I like. It means I can "overload" it whatever functionality I want to in a given pipeline.

woojamon avatar Jan 30 '23 19:01 woojamon

just stumbled across Grain and it's such a nice language! I also really admire the goal. I wanted to add my two cents here and put another vote in for the "Uniform call syntax" (wikipedia) mentioned by @tredeneo. Here are some of the primary reasons I believe it would be a great choice for Grain:

  • a primary goal of Grain is to introduce useful functional features to engineers working in mainstream languages (primarily javascript developers?). The dot call syntax is extremely common and familiar, so it's much less likely to scare people off compared to a pipe operator. New team members don't necessarily even need to learn a new concept/symbol when onboarding to a Grain project—they can just call methods like they're used to and learn the semantic difference between OOP class methods and uniform call syntax when the need arises.
  • The syntax neatly provides scoped autocomplete in the IDE (pipe operator could too, but I imagine IDEs and intellisense are especially tuned for autocompleting dot calls). It's muscle memory for many developers to type a variable name, hit . and expect to see a list of methods/fields. Not quite as smooth an experience when using pipes
  • it elegantly solves several problems with one bit of syntax sugar and avoids introducing additional unnecessary concepts like "extension methods" (kotlin, c#), class methods, static builder methods, or the rest of that OOP baggage
  • the resulting code is likely to be functional in the sense of separating data and functions, but looks "OOP enough" to avoid people turning their noses up at it.
  • it guides OOP-minded engineers towards writing functional code instead of leading them to put methods in record types for this familiar syntax
  • lots of utility libraries and existing code can be reused!
  • compared to module prefixed calls like List.map(x => x * 2), the code reads left to right, which is much more intuitive than reading "inside out"
  • related, the code reads in order of Subject-Verb-Object (e.g. cow.eat("grass")) which is how English is structured (code is written in English for better or worse)
  • reduces redundant arguments and boilerplate present in module prefixed calls (myMap.set(a, b) vs Map.set(a, b myMap))

Anyways, I put together this little example to get a feel for what it'd looks like and add some more context/examples:

Before:

import List from "list"
import Map from "immutablemap"

// current awkward way to get dot call syntax without using modules.
// Goes against the functional grain
record Database<item> {
    getStore: () -> Map.ImmutableMap<Number, item>,
    insert: (Number, item) -> Result<String, String>
}

// good! Keeps data and functions separate
record Person { id: Number, name: String, age: Number }
// currently awkward and inconsistent to use these since they can't be called with dot call syntax
let greet = (person: Person) => List.join("", ["hello ", person.name, "!"])
let save = (person: Person, db: Database<Person>) => db.insert(person.id, person)

// db impl is awkward. The db instance needs to reference another store instance instead
// of the methods being self-contained
let mut store = Map.fromList([])
let db = {
    getStore: () => store,
    insert: (id: Number, person: Person) => {
        if (Map.contains(id, store)) {
            Err("person already exists")
        } else {
            store = Map.set(id, person, store)
            Ok("saved person")
        }
    }
}

// forced to use different calling conventions depending on where/how the function was defined
let johnny = {id: 0, name: "Johnny", age: 33}
print(db.getStore())
print(save(johnny, db))
print(db.getStore())
print(save(johnny, db))

After:

import List from "list"
import Map from "immutablemap"

// good! Keeps data and functions separate
record Person { id: Number, name: String, age: Number }

// "class methods" can be split out into multiple files instead of accumulating in a record type
let greet = (person: Person) => "".join(["hello ", person.name, "!"])
let save = (person: Person, db: Database<Person>) => db.insert(person.id, person)

// the more complex database example now doesn't need an extra layer of nesting, which helps keep code cleaner
record Database<v> { mut store: ImmutableMap<Number, v> }

// technically could clean up and don't even need this method anymore
let getStore = (db: Database<v>) => db.store

// easier to refactor and make this generic in the future to support types other than Person
let insert = (db: Database<Person>, id: Number, person: Person) => {
    if (db.store.contains(id)) {
        Err("person already exists")
    } else {
        db.store = db.store.set(id, person)
        Ok("saved person")
    }
}

let db = { store: Map.fromList([]) }

// calling code can be more consist in its convention and use dot calls or function calls as desired for readability
let johnny = {id: 0, name: "Johnny", age: 33}
print(db.getStore())
print(johnny.save(db))
db.getStore()
    .get(0)
//  .print() // easy to comment out pipeline steps when debugging

save(johnny, db).print() // no editor gymnastics needed to quickly add a println

// all normal method calls still work
insert(db, 1, { id: 1, name: "Jimmy", age: 34 })

Anyways, thanks for taking the time to consider this (and for all the hard work on Grain so far)!

jsnelgro avatar Dec 10 '23 20:12 jsnelgro

@jsnelgro thanks for the thorough feedback! I'm personally a fan of this feature in other languages that support it but I think that the current features in Grain provide a hostile environment for the existence of this syntax, and supporting it would require some significant overhauling of the language to make it convenient to use. Some thoughts:

  • Most utility functions for Grain types live in their own separate modules (like String for string functions) rather than in the "globally imported" Pervasives, which is intentionally kept minimal for only core/common functions like print, (+), etc. To the point of making a language that is ergonomic to developers coming from OOP languages, someone may be confused why "abc".length() does not work in a vacuum. Since the length function for a string rather exists in the String module, you would either first have to do something like import { length } from "string" to bring the function into the context of your file or have a way to explicitly indicate where the length function lives, perhaps something like "abc".String.length(). In either case, this extra boilerplate and a mental leap into Grain's way of doing things would already be required if coming from an OOP language, and it may be disappointing to not have what you were looking for show up in autocomplete when typing . on a string.
  • Grain does not support function overloading and a function's definition is rather the latest definition for that identifier (this feature is called "shadowing"). For example:
    let myFunc = () => print("first")
    myFunc() // will print "first"
    let myFunc = () => print("second") // this definition overwrites/shadows the previous one
    myFunc() // will print "second"
    
    Let's say from the first point you were OK with doing import { length } from "string" to be able to do "abc".length(). This may be fine until you also want to use this syntax for an array, in which case you might also do import { length } from "array". However, now you have overwritten the definition of the length function to be an array's length, so "abc".length() would no longer work.

The |> syntax definitely may look weird to an OOP programmer, though I personally don't think it would be too big of a hurdle to cross; there's also a proposal to add this syntax into JS https://github.com/tc39/proposal-pipeline-operator, so it may become more commonplace in the future. I also think that the points you bring up of left-to-right reading and subject-verb order would be alleviated by a |> operator, just with a different syntax.

alex-snezhko avatar Dec 10 '23 23:12 alex-snezhko

Ah, these are all great points. Uniform call syntax doesn't work well without function overloading. In your string example, I guess you'd end up with one big pattern matching function that took a union type of everything that could have a length. Aside from being messy, it'd also be impossible for library clients to extend it. The imports issue doesn't seem too bad since most IDEs can figure out the imports for you (Kotlin extension methods in IntelliJ work like this and it's a nice DX). Given the language constraints, I agree the pipe operator makes more sense. And it's not too big of a mental leap to figure it out. Hope it shows up in JS soon 🤞

jsnelgro avatar Dec 11 '23 03:12 jsnelgro