grain-lang / grain

The Grain compiler toolchain and CLI. Home of the modern web staple. 🌾
https://grain-lang.org/
GNU Lesser General Public License v3.0
3.29k stars 114 forks source link

Lang: `|>` reverse application operator #403

Open ospencer opened 4 years ago

ospencer commented 4 years ago

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 commented 4 years ago

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.

bmakuh commented 4 years ago

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

ospencer commented 4 years ago

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

bmakuh commented 4 years ago

ah ok, no worries

Simerax commented 2 years ago

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.

timjs commented 1 year ago

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 :-)

tredeneo commented 1 year ago

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)

alex-snezhko commented 1 year ago

@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

woojamon commented 1 year ago

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)
ospencer commented 1 year ago

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

woojamon commented 1 year ago

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.

ospencer commented 1 year ago

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.

woojamon commented 1 year ago

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 commented 1 year ago

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.

jsnelgro commented 11 months ago

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:

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)!

alex-snezhko commented 11 months ago

@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:

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.

jsnelgro commented 11 months ago

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 🤞

crush-157 commented 2 days ago

Is this feature being currently worked on (last comment being almost a year ago)?

spotandjake commented 2 days ago

Is this feature being currently worked on (last comment being almost a year ago)?

Currently this still does not exist in the language though you can define it as:

let (|>) = (a, f) => f(a)

We even expose this in the json module for working with lenses currently

Without partial application (there is a pr for this open currently) and currying implemented just yet it wouldn't be the most useful as there would be no way to chain together a lot of the standard library functions, and as mentioned above we would probably want to look into having specific optimizations for this operation.