Tacit Golang?

266 views
Skip to first unread message

Sam Hughes

unread,
Apr 1, 2022, 6:41:52 PM4/1/22
to golang-nuts
Point-free programming, or "tacit programming",  is a convention that highlights the intent without syntactic noise. 

For those unfamiliar, wikipedia: https://en.wikipedia.org/wiki/Tacit_programming

I want better function composition. "Write a helper function to route values out to values in, like a normal person." Sure, but who needs Go when you have C, eh? A tacit sugaring merely provides a lexically distinctive means to indicate the intent that function f0 should be called with the return value of f1, and f1 of f2, and so on, with reduced syntax.

There are a couple layers here, there's an easy win, a possible next step, and well, madness.

A. Go uses a thin-arrow, "<" + "-", as a receive/send operator and to signify directionality of a channel. This same operator could be used like so, "c := a <- b()" to assert the following:
    1. Token "a" resolves to a function pointer
    2. Token "b" returns none, one, or many values, such that "a(b())" is legal.
    3. Token "c" can legally be assigned the value of the desugared expression.
This suggestion imposes ambiguity between the meaning of "(chan int)(x)<-y(z)" and "(func(int)int)(x)<-y(z)", and I have chaotic intuitions about the meaning of "(func(int)int)(x) <- (chan int)(y)", or even "(func(int)int)(x) <- (chan int)(y) <- z", but x(<-y) is clearly (func[T](T)T)(x)(<-(chan[T] T)(y)). A minimally disruptive solution, then, is to assert that the tip of such a tacit chain must be a full-syntax invokation, e.g. for "f0 <-...f<N-1> <- ?", only "f<N>(...)" is valid. This means expressions like "c0 <- f0 <- f1(f2 <-f3(<-c1))" are unambiguous without a mountain of ellipses.

B. At cost of introducing a new concept for Go entirely, it would be convenient to declare a function as "f0 := f1 <- f2 <- f3", resolving as suggested by the statement: "reflect.TypeOf(f0) == reflect.FuncOf([]reflect.Type{reflect.TypeOf(f3).In(0), ..<etc>}, []reflect.Type{reflect.TypeOf(f1).Out(0), ...<etc>})". The straightforward path to implementation would resolve that naively, as suggested by the following: "f0 := func[T handWaving.inT, U handWaving.outT](a... T) (...U) {return f1(f2(f3(a...)))}". A statement like "go f0 <- f1 <-f2 <- f3", assuming "go func[T handWaving.inT](a...T) {f0(f3(a...));}" is legal, would be an attractive pattern.

C. Naively, point B suggests that the functions thus concatenated could be assembled as to preallocate the stack for the entire concatenation, omitting the allocations and moves/copies between function calls, and rather writing the return values to the stack for the predecessor function, by analogy, like a reverse closure or a heterogenous recursion. For the example "f0 := f1 <- f2 <- f3", because I expect that statement to only be legal if "f1(f2(f3(..args..)))" is legal, the out-signature of f3 is assignment-compatible with the in-signature of f2, and f2 to f1. Concerns such as an element in the concatenation being recursive, blocking, or corrupting only apply to the function at stack-head; pre-allocating even a potentially large stack exposes only risk shared with allocating the same stack progressively, but with lower instructional segmentation. A possible but unlikely edge-case, if for some reason, a generic function cannot be appropriately stenciled, the optimization being suggested might only partially be applied, or else not applied at all. 

Ellipses are basically universal for "I give you control, but I expect it back". Loops don't give up control, rather push it on a swing like a child. Go-func commissions an agent, expecting no control. Point A would change the expression somewhat, but by requiring the head of a concatenation to be an execution, the language of "I give, but I expect" is kept, but uses the existing language of sending on channels,  "B to A, your turn A", and is suggested as unambiguously extending the "I give, I expect" agreement backwards towards the tail. The symbols suggested are certainly not sacred, either.

Point B and C are harder to recommend unequivocally, but could be potently powerful tools for communicating very long but uncomplicated calls. Point B has very few downsides, but represents introduction of a case where a human might understand an expression differently than a parser, while Point C introduces a layer of complexity to serve a use case that, for better or worse, could serve to push a new idiom onto the Gopher community. 

These three points present no risk to the Go 1 compatibility promise, but I will admit before anyone else that I haven't proven that Point C is worth the investment, and I expect some difficult questions about Point B.

Henry

unread,
Apr 1, 2022, 11:35:06 PM4/1/22
to golang-nuts
Something like this? https://play.golang.com/p/rhu9PX9GSbp

Sam Hughes

unread,
Apr 2, 2022, 5:24:58 PM4/2/22
to Henry, golang-nuts
Yep. That’s the status quo. My tongue-in-cheek response to that as objection is “If simply sufficient utility for a given task were valid as objection to a new utility, why Go over C?” If you’re not posing that as objection, no need to dwell on it.

Yeah. That’s exactly the kind of behavior I want to improve on. If you reimplemented as “[T constraints.Number]”, then you would have a reasonably efficient means of expressing even very deep pipelines of numerical transformations, with the caveat that one is not able to change the arity for any stage boundary. You can’t reduce the constraint on T, lest you be unable to do mathematical operations without reverting to interface and type-assertion semantics, losing the benefit of generics over interfaces. Now you have two sets of almost identical pipeline boilerplate — more if you intend stochastic gradients or other matrix operations.

Generics also can’t cover the case of multiple values at the boundary of a stage. You could define types for various combinations of values, function pointers, and errors, and create the most godawful, disgusting pile of moldering spaghetti.

The three most effective ways to get around this are to A, marshal/unmarshal from a byte-slice, B, use variadic parameters of type empty interface, and C, codegen. All three add unnecessarily heavy mental overhead, and excepting C, sacrifice safety and performance guarantees.

So in short, yes, that playground snippet is exactly the kind of utility that works in the same way that a tape-covered racket works as an oar.

--
You received this message because you are subscribed to a topic in the Google Groups "golang-nuts" group.
To unsubscribe from this topic, visit https://groups.google.com/d/topic/golang-nuts/Iv2kyFY70eA/unsubscribe.
To unsubscribe from this group and all its topics, send an email to golang-nuts...@googlegroups.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/golang-nuts/82780772-5819-412b-a238-87f53ec22f77n%40googlegroups.com.

Bakul Shah

unread,
Apr 2, 2022, 6:26:29 PM4/2/22
to Sam Hughes, golang-nuts
If I understand you right, you seem to want to use "<-" as the "compose" higher order function but as an infix operator. That is, instead of "value := F(G(args...))", you want to be able to write "value := (F <- G)(args...)". Further, "F <- G <- H" means "F <- (G <- H)". F, G &H are functions such that F(G(H(args...)) is a valid expression, if args... is valid.

My gut instinct says Go is the wrong language to extend for tacit programming. If you look at existing code, my guess is you won't find very many instances of code that can be mapped to tacit programming. So low "bang for the buck". Second the optimization that you talk about in point C. is likely not very important or even meaningful. Typically it is the *called* function that pops the stack, not the caller and unless you can inline things like (F<-G<-H) you can't collapse three pops into one. Third, in Go you can't short circuit error returns. Thus if H can return an error, you can't use it in a pipeline! You have to handle the error at the call site of H. Ideally one (or at least *I*) would want something like F(G(H(...)) to return to the call site of F with an erorr, without executing F or G in case H fails.

--
You received this message because you are subscribed to the Google Groups "golang-nuts" group.
To unsubscribe from this group and stop receiving emails from it, send an email to golang-nuts...@googlegroups.com.
To view this discussion on the web visit https://groups.google.com/d/msgid/golang-nuts/ab4e2604-99aa-4164-a2bf-a9da29d9e0d2n%40googlegroups.com.

Sam Hughes

unread,
Apr 3, 2022, 8:29:11 PM4/3/22
to Bakul Shah, golang-nuts
You’re not far, but a couple corrections:

1. While a decent, native “compose” function would skip straight to point B, and as such would also accomplish point A, even with generics this is cumbersome.

2. Point A describes implementation of an infix pipe-operator, something implemented by nature of stack-based languages, but also in Bash, Elixir, Haskell, F#, Hack, R — a closed-alpha language sponsored by NoRedInk, Roc, even supports both left-right and right-to-left piping. Often the symbol used to signify the pipe operator is a vertical bar, with or without an angle brace. I am suggesting the “<“+”-“ symbol because it already has a meaning that is similar. Point B does bring up compositionally creating a new function, but point A is just dealing with the pipe semantics.

3. Where you write “value := (F <- G)(args…)”, you are describing the suggestion of point B, not Point A — a visible invokation must occur, per A, to clearly communicate that a function execution is occurring. The distinction only matters if Point A becomes accepted without Point B.

4. I don’t recognize your point about Go being somehow unfit for the pattern. A package written in an OOP style may benefit only rarely, but when a package is more a library of combinators and propagators, such as in ML and HPC, it is quite normal to encounter a pile of elipses, with some tokens buried in the middle. I would use the heck out of this tool of expression.

5. I fail to see the point of raising the question of calling convention. I understand that what I’m suggesting in Point C requires new behaviors on behalf of the assembler, and many more tools besides. 

6. I again fail to see your point by complaining of errors. An error is a value, and that is all. If one of the functions panics, emitting the correct stacktrace would certainly be another point of cost for implementing
point C,  but for the example “v, e := f0 <- f1 <- f2(x, nil)”, if all of the stages can fail, an effective pipeline would be composed of stages which each short-circuit if the second argument is not nil,  returning zero values and the received error. This is how one currently composes pipelines. The difference proposed by point A is that it’s much more readable. 

Obviously, Points B and C depend on Point A being recognized
Reply all
Reply to author
Forward
0 new messages