Hacker News new | ask | show | jobs
by andolanra 1849 days ago
I think people have a habit of taking relatively surface-level features of functional programming and focusing on them to the exclusion of the real benefits of functional programming. The use of something like compose3 in the "functional" example here is a perfect example. Sure, function composition is a "functional" thing, but I don't see why you wouldn't instead write something like (handwaving wildly on the specifics, since I know barely any Go and certainly am not up to speed on the generics proposal):

    func getTopUsers(posts []Post) []UserLevelPoints {
            return posts.GroupBy(func (v Post) string { return v.Level })
                        .Values()
                        .Map(getTopUser)
    }
This pipeline style is significantly easier to read (especially without having to put all those extraneous type parameters in your call to compose3!) and doesn't actually lose any of the core advantages of the functional style: purity, testability in isolation, equational reasoning, and so forth. Sure, the Haskell equivalent might use composition… but composition reads more or less naturally in Haskell, and I don't think it does at all here in Go. If your specific approach to "functional programming" makes your code theoretically easier to reason about but practically harder to both read and write, then is it really helping you much?
3 comments

Yeah if you're writing "Compose3" you've already lost. The extra type annotations only make it worse.

    x := foo()
    y := bar(x)
    z := baz(y)
Wow.
Ok, now what if foo, bar and bar are async? Nullable? Result types?
Well it's not like Go has do notation so I don't know what you're expecting here.
I’m pointing out that functional programming goes beyond trivial things like compose3 and list operations (useful though they can be).
Sure, but e.g. monadic futures aren't going to be very usable in a language without do notation, custom operators, or non-verbose lambdas. Might as well just use channels. As for nullability and error handling, writing "if" over and over again might be bad, but in Go I don't think functional approaches are going to be any better. Maybe liftA2, etc. on pointers would be usable, but not much else.
Piling on here - My impression of your snippet here is that GroupBy() and Values() both construct (potentially very large) temporary arrays on the stack.

Maybe Haskell and F# can elide this kind of thing, but it's not something the Go compiler is going to do.

Rust and OCaml (the latter if you use the right libraries) can definitely avoid it. Why couldn't Go?
The standard Ocaml compiler doesn't elide it and as far as I'm aware neither do any of the publicly available libraries. All of the lazy stream implementations in Ocaml have a performance cost similar to Java streams. Haskell and Rust are relatively unique in that they can completely eliminate the performance cost in the typical cases.
An alternate-universe Go with a lot more (& slower) compiler passes could do it, but I don't believe there are any plans to build such a thing. As it stands, this kind of code (where all intermediates have type []T) will run much more poorly than you could do with imperative loops.

The final generics design is probably not expressive enough to do it ergonomically in user code neither (building something where the intermediates are a library type with a last `.something()` call to collect a final []T).

OCaml compiles as fast Go.

No secret sauce, just having the pleasure of chosing multiple backends, including an interpreter.

Bytecode runtime during workflow, optimized native AOT for release and final production tests.

That's just bad design - iterators and sequences need to be lazy. The intermediary type should not eagerly create large arrays on the stack, it should only ever do real work once the iterator has been driven.
No, they don’t need to be. They can be. Go wasn’t designed as a functional language but to be fast to compile, fast to execute, zero dependency binary and simple syntax. It achieved that by a mile.
Implementing iterator combinators by eagerly evaluating them into temporaries is a bad, naïve design that results in the issue illustrated above.

A much better design is to do it lazily which is possible if you can store closures as fields of a data structure.

Not sure what fast compilation and execution or dependencies has to do with this. Implementing iterators that way is the worst possible solution that results in slow execution that can blow up the stack and crash a program. It would be better not to have iterators at all than to require them to be eagerly evaluated.

You can support functional features without being a Haskell...

That’s probably okay if the code is I/O bound anyway, but in Go it will run a lot slower than doing multiple steps within a single for loop.

For loops may be unfashionable but they aren’t hard to read.

I don't find iterator combinations hard to read. Especially with postfix methods.

I do find deeply nested for loops with outer variables that might start uninitialized or need to be mutable much more difficult to read.

I agree they need to be lazy. This is why it's problematic - Go is only adding generics and not laziness.
I've been waiting to do things like that in Go for a long time. It can be done efficiently just like the Java Stream interface.
FWIW C# would as well (LINQ)
Exactly my thought: why is this clearer?

I'm often think FP-first (data science) -- here though, I was surprised by how I went back over the imperative alternative.

Avoiding `append()` isnt worth it in a language where this level of annotation is required to do FP. It's less clear.