top | item 39480331

Tacit programming

134 points| tosh | 2 years ago |en.wikipedia.org | reply

92 comments

order
[+] mihaic|2 years ago|reply
Absolutely every single time when I use functional programming, I store my intermediary calls in a variable, specifically because naming that variable forces me to explain what that intermediary result should be.

If the intermediary result makes no sense, and only the function composition makes sense, I'll create a new well named function that does the chaining, even if it's single use.

This is literally the only way I've seen multi-year projects be maintainable, and anything else eventually breaks down into uninteligible code.

Writing code for other developers in the long-run is empathy for yourself, since you'll eventually forget all the context you once had.

[+] jonahx|2 years ago|reply
Doing this for every intermediate result badly harms readability.

Consider McIlroy's famous 1-liner:

    tr -cs A-Za-z '\n' |
    tr A-Z a-z |
    sort |
    uniq -c |
    sort -rn |
    sed ${1}q
Adding intermediate variables like "newline_delimited", "lowercased", "sorted", etc... is just pure noise. It's the equivalent of a newb programmer putting a comment over each line of simple code explaining in English what that code does, despite it being clear already.

"There are only two hard things in Computer Science: cache invalidation and naming things"

One of the great benefits of pipelines and tacit programming is the ability not to name intermediate results, especially when those results speak for themselves, are not re-used, and have no significance in isolation.

[+] mirekrusin|2 years ago|reply
Tacit programming shines when you have combinators over some closed, well defined domain - ie. parser combinators, runtime assertion combinators, iterator combinators etc.
[+] marcosdumay|2 years ago|reply
Well, none of that depends on your functions being point-free or you writing their parameters down.

The Haskell culture in particular has a very strong idea of naming all the things that can have a good name, and none of the ones that can't. Point-free functions are about that last part, but you only touched the first part. (Personally, I think the culture is too radical, but you can't really argue against a principle like this.)

[+] kjqgqkejbfefn|2 years ago|reply
I've been using tacit programming intensively to the extent I get rid of most variables. I use both syntactic threading macros and functional combinators to achieve this. It is a double-edged sword in that it can make code as ugly as the original code it is trying to improve upon, but working without variables isn't that difficult nor does it lead me to intense clusterfucks. Composing lambdas contribute to this a lot more though.

As for making things clearer for everyone, well, this is code I work on solo (hobby), but I think providing example inputs as well as debugging macros can help a lot. Consider the following:

    (defn parse-it [dependencies]
      (pp->> dependencies
             str
             str/split-lines
             (keep (|| re-matches #"- (\d+(?:\.\d+)?) -> \[((?:\d+(?:\.\d+)?(?:, ?)?)+)\]"))
             (>>- (map-> (juxt-> second
                                 (->> third (re-seq #"(?:\d+(?:\.\d+)?)")))))))
    
    (parse-it (str "- 4 -> [1, 2, 3]\n"
                   "- 5 -> [4, 2]\n"
                   "- 6 -> [1, 2, 5]"))
    
    ;; The use of pp->> will lead to this getting printed
    ;; ->> dependencies                              : "- 4 -> [1, 2, 3]
    ;;                                                  - 5 -> [4, 2]
    ;;                                                  - 6 -> [1, 2, 5]"
    ;;     str/split-lines                           : ["- 4 -> [1, 2, 3]"
    ;;                                                  "- 5 -> [4, 2]"
    ;;                                                  "- 6 -> [1, 2, 5]"]
    ;;     (keep (|| re-matches #"- (\d+(?:\.\d+)... : (["- 4 -> [1, 2, 3]" "4" "1, 2, 3"]
    ;;                                                  ["- 5 -> [4, 2]" "5" "4, 2"]
    ;;                                                  ["- 6 -> [1, 2, 5]" "6" "1, 2, 5"])
    ;;     (>>- (map-> (juxt-> second (->> third ... : (("4" ("1" "2" "3"))
    ;;                                                  ("5" ("4" "2"))
    ;;                                                  ("6" ("1" "2" "5")))
In the end I think it doesn't really bring a lot, but it's especially useful in making short piece of code more readable:

    (->> [1 2 3 4]
         (map (when| odd? inc)))
    ;; vs
    (->> [1 2 3 4]
         (map (fn [x]
                (if (odd? x)
                  (inc x)
                  x))))
    ;; result (2 2 4 4)
Or this:

    (-> k-or-ks (when-not-> coll? list)
        (map-> ...do-something))
    ;;vs
    (let [ks (if (coll? k-or-ks)
               k-or-ks
               (list k-or-ks))]
      (map ...do-something
           ks))
Or even this:

    (-> 1 (juxtm-> :incd inc :decd dec))
    ;; vs
    (let [n    1]
      {:incd (inc n)
       :decd (dec n)})
Granted I have insane shits like this teleport arrow (very useful though):

    (-> '(1 2)
        (•- (conj (-• first dec)))) ;; => (0 1 2)
Or this >-args "fletching"

    (-> {:a 1 :b 2}
        (•- (-> (>-args (-> (/ (-> :a) (-> :b))))
                (->> (assoc (-•) :result))))) ;; => {:a 1, :b 2, :result 1/2}
I actually write this kind of stuff in my code ahahaha (the right move is to implement assoc-> of course hahahaha). Now there are combinators I wrote I never use, like departializers, unappliers, argument shifters, etc
[+] auggierose|2 years ago|reply
Yeah, point-free sounds cool, until you actually try it out. Even in their example they are not point-free:

    compose(foo, bar, baz)
Here compose is applied to three "points" (which happen to be functions).
[+] siraben|2 years ago|reply
Why not translate your code to pointfree style automatically? Using[0], you can go from

  quad a b c = let d = b * b - 4 * a * c in ((-b + sqrt d) / 2 * a, (-b - sqrt d) / 2 * a)
to

  ghci> import Control.Monad
  ghci> quad = ap (ap . ((.) .) . ap (ap . (liftM2 (,) .) . flip (flip . ((*) .) . flip flip 2 . ((/) .) . (. sqrt) . (+) . negate)) (flip (flip . ((*) .) . flip flip 2 . ((/) .) . (. sqrt) . (-) . negate))) (flip ((.) . (-) . join (*)) . (*) . (4 *))
  ghci> quad 1 3 (-4)
  (1.0,-4.0)
[0] https://pointfree.io/
[+] yau8edq12i|2 years ago|reply
Apart from "just because we can" or "it's fun", why on earth would someone prefer the second style?
[+] TuringTest|2 years ago|reply
I love the concept of point-free programming - write your function by simply concatenating the transformations you want.

I just hate reading the resulting code written by others. What information is expected to come in, and exactly what data passes from one step to the next, and in what position? Data type signatures only go so far.

Point-free means you have all that wiring in your head, without assistance from the notation.

[+] anonzzzies|2 years ago|reply
I like reading and writing point-free code, I just really hate debugging it. The debuggers/IDEs are (usually? are there exceptions) not really geared up for it and so the debugging experience is basically one of just having 1 call. This goes for more lambda style calling mechanisms of course. I end up pulling it apart, but that's only because of bad debug/ide support in my case. I can read/write it fine; in most cases it just works and then I like it better than the (verbose) alternatives.
[+] trealira|2 years ago|reply
> Point-free means you have all that wiring in your head, without assistance from the notation.

I completely agree. It fatigues me to read unnecessarily point-free programming. I have to translate it into a point-ful style in my head to understand it.

For example, you could take this piece of Haskell code and make it more point-free. I think it's readable at first, if redundant (you could remove the last parameter xs, for example).

  -- map: apply the function f to each element of the list xs
  map :: (a -> b) -> [a] -> [b]
  map f xs = foldr (\x xs -> f x : xs) [] xs
Some people would prefer to write it like this:

  map :: (a -> b) -> [a] -> [b]
  map f = foldr ((:) . f) []
That lambda takes more effort to parse and think about, though, at least for me. The first version was pretty readable. Now, when I read "((:) . f)" I'm thinking "okay, so the function f takes an argument, and passes the result to the (:) function, which normally takes two parameters; with one argument, it returns another function that takes one list parameter and returns it with the result of "f x" prepended to it." And to do this, I have to know implicitly how many arguments the function (:) takes in order to parse and understand it correctly (though in this case, it's obvious, because (:) is ubiquitous).

Pointfree.io would take what I wrote and transform it into

  map = flip foldr ([]) . ((:) .)
But I'm pretty sure no one would write that. It takes even more effort to correctly parse this.

That said, I don't write any Haskell. I just used to try to, but found I didn't like it.

[+] tonyarkles|2 years ago|reply
I'm smiling a bit because I know exactly what you mean and generally agree, with an exception. A common idiom in Elixir is to return {:ok, result} or {:err, :reason} from calls that can fail. Leaning on that idiom, good function names, and good errors goes a long way:

    params[:id]
    |> find_user
    |> verify_user_has_access_to(params[:post_id])
    |> etc
When I define the functions I'll use pattern matching on the first argument to each function so that you can either pass in a "user" or {:ok, user} or {:err, _}. If it matches the error pattern it'll just return the error unmodified. The errors have enough fidelity to make it clear where the pipeline failed. I'm not sure this is a super common pattern though but it worked pretty well for me.
[+] astrobe_|2 years ago|reply
Just like there is "decision fatigue" [1] I believe there is "naming fatigue", and naming are one of the three great problems of computing, as everyone knows.

I'd say that at least point-free prevents naming fatigue, but for the result to be nice to the reader, the naming and factorization is much more important than with explicit code, which has sort of much more "safeties".

On the specific question you ask, let's hear the creator of one of the major stack oriented languages, Forth, by its inventor when he had about 30 years of professional practice. He talks about code comments, which is the local solution that comes immediately to mind when thinking about the issues you pointed out:

So people who draw stack diagrams or pictures of things on the stack should immediately realize that they are doing something wrong. Even the little parameter pictures that are so popular. You know if you are defining a word and then you put in a comment showing what the stack effects are and it indicates F and x and y

F ( x - y )

I used to appreciate this back in the days when I let my stacks get too complicated, but no more. We don't need this kind of information. It should be obvious from the source code or be documented somewhere else.

Well, comment-less code isn't popular either, but we're not here to design the next Python anyway. By the way, Chuck Moore evolved his language until he determined that the remaining problems were "in the hardware". He did his own CAD tools with his language to design stack-oriented CPUs with some success, as some of them ended up in spacecrafts (notably Philae in 2014 [3]).

In my experience, when you want to keep the data flow as simple as possible (in Forth: keep "stack juggling" to a minimum), there's very often only one good order for parameters. That works very well for the writer, for whom the understanding of the problem helps with memorizing signatures and semantics. As a reader (of other's code), I have much less experience, but I guess that understanding the same things requires the readers to invest more time up front, in addition to the "accidental obfuscations" (poorly written code, unusual programming habits and conventions...).

[1] https://en.wikipedia.org/wiki/Decision_fatigue [2] https://www.ultratechnology.com/1xforth.htm [3] https://en.wikipedia.org/wiki/RTX2010

[+] sherburt3|2 years ago|reply
I think this style requires familiarity with the codebase that its using before it actually becomes readable, but once you have that familiarity reading and understanding what's happening is a lot faster. Like Rxjs for example, if you're unfamiliar with the library its like hieroglyphs, but once you develop familiarity with it you can read other people's code much faster than if everything was implemented procedurally. That said if people are paying me money I usually avoid doing point-free, except for Rxjs. I love Rxjs so much.
[+] jan_Inkepa|2 years ago|reply
The wiki page leaves out the source of the the 'point-free' nomenclature - category theory ( e.g. https://en.wikipedia.org/wiki/Pointless_topology ). The original game was talking about sets (or other similar objects) without talking about 'set membership'/'elements', whence 'point-free'; you want to only talk about functions between sets (or morphisms between objects), and build everything up from those.

Is 'tacit' an old term or a newer rebranding?

(edit: https://en.wikipedia.org/wiki/Whitehead%27s_point-free_geome... might be earlier than the category stuff, but not sure if it used the 'point-free' nomenclature, nor if it has a direct lineage to the later category theory stuff )

[+] mlochbaum|2 years ago|reply
The name "tacit" comes from the APL family as far as I know. It certainly fits with Iverson's style, as he was fond of seeking out just the right word to describe something regardless of obscurity ("ravel", "copula", etc.). I think the name would have come about after the development of function trains in 1988, and I found a paper "Tacit definition" about Iverson's J from 1991: https://dl.acm.org/doi/10.1145/114054.114077 (digitized at https://www.jsoftware.com/papers/TacitDefn.htm). Not knowing when "point-free" started to be applied to programming, I can't say which is first. I doubt J's developers were aware of "point-free" in any case.
[+] layer8|2 years ago|reply
> The wiki page leaves out the source of the the 'point-free' nomenclature

The wiki page explains it in the very first sentence: “… in which function definitions do not identify the arguments (or "points") on which they operate”.

[+] dang|2 years ago|reply
Related. Others?

Tacit Programming - https://news.ycombinator.com/item?id=28413195 - Sept 2021 (1 comment)

What’s the Point of Pointfree Programming? - https://news.ycombinator.com/item?id=25509468 - Dec 2020 (1 comment)

When Does Point-Free Notation Make Code More Readable? - https://news.ycombinator.com/item?id=15365214 - Sept 2017 (73 comments)

Programming in the Point-Free Style - https://news.ycombinator.com/item?id=14077863 - April 2017 (101 comments)

Point-Free style: What is it good for? - https://news.ycombinator.com/item?id=1175946 - March 2010 (22 comments)

[+] adityaathalye|2 years ago|reply
Tinkering with APL (Dyalog) gave me one of my most mind-bending programming moments.

  dismal ← 10⊥(⌈/10⊥⍣¯1⊢)
This is the complete solution to addition in the framework of Dismal Arithmetic [1].

The pivotal idea there was the inverse of a function, and "trains". Until that moment of insight, I was fiddling about with dfns, which looks janky in comparison.

  dismal ← {10(⊤⍣¯1)⍵}∘{⌈/⍵}∘{10(⊥⍣¯1)⍵}⊢
⍣¯1 is APL for "inverse". Aaron Hsu helped me understand [2] what was going on.

I feel like this kind of conceptual power is tacit programming at is finest. It blows my mind that this alien gobbledygook code still makes sense to me after not touching APL for years now. And I've only toyed around with the language.

[1] I was playing with the problem to compare Clojure and APL, just because. https://www.evalapply.org/posts/dismal-arithmetic-dyalog-apl...

[2] https://www.sacrideo.us/decoding-inverses/

(edit: fix typos, verbiage)

[+] DrDroop|2 years ago|reply
Interesting, never heart about dismal arithmetic before. Squint hard enough and it starts to look like tropical geometry. This is a different construction where you give the addition and multiplication operators in a polynomial a different meaning. I've been trying to have a better understand why this is useful. I know tropical geometry has been used to improve things like price discovery, but I never got to a better understanding as to why. I At any rate I would be curious to know what dismal arithmetic can do for me.
[+] prezjordan|2 years ago|reply
I can't seem to figure out what you mean by inverse. Is the inversion of addition subtraction? Neither post seems to explain what it is (or maybe it assumes knowledge of ⊥ and ⊤?)
[+] tangus|2 years ago|reply
>The pivotal idea there was the inverse of a function

I was curious about this piece of unexplained cryptic code, so I did a little investigation to see what's going on. Unfortunately there aren't any revolutionary concepts here, just esoteric notation. I'll explain:

To do that "dismal addition" thing, you need to split a number into digits and build a new number using the largest digit at each position. dismal(123, 321) = 323.

APL gives you an operator to make a number out of a sequence of digits: that inverted T you see in OP's code. The left operand is the base. `inverted_T(10, [1, 2, 3]) = 123`.

It gives you another operator to split a quantity into a hierarchy of units. That's the Tee you see in their second snippet. The left operand is a sequence of radixes. An inch is 2.54 cm, a foot is 12 inches, a yard is 3 feet. To transform 130 cm into ft/yd/in, you'd do: `Tee([3, 12, 2.54], 130) = [1, 1, 3.18]`.

So, OP wanted to use this Tee operator to split a number into digits. The problem is, they don't know beforehand how many digits the number has! If it's 2 digits, they must do `Tee([10, 10], number)`. If it's 3, they must do `Tee([10, 10, 10], number)`. (Because `Tee([10, 10], 123) = [12, 3]`). So in the second snippet they tried to do some juggling to get the number of digits and use it in the Tee function (I guess).

What OP really needs is the inverse function of inverted_T. And wouldn't you know it, APL can give you the inverse of a built-in function or a sufficiently simple user function. How? Maybe an operator? No...

See that operator that looks like a puckered face? That operator applies the function to its left, as many times as the operand to its right, to whatever is to the right of the sideways T. BUT, if the right operand is negative, it applies the inverse of the left operand. Basically, the all-powerful "invert function" operation is hidden as a special case of another operator...

In sum, here's my interpretation of OP's code in pseudocode:

Using:

    encode(base, seq) = <base> inverted_T <seq>
    max(a, b)         = a gamma b
    reduce(fn, seq)   = <fn> slash <seq>
    superapply(fn, times, seq) = <fn> puckered <times> sideways_T <seq>


    let dismal =
      encode(10, reduce(max, superapply(encode(10), -1)))
So,

    dismal([123, 321, 111])
applies the inverse of `encode(10)` one time to each sequence item, giving:

    [[1, 2, 3], [3, 2, 1], [1, 1, 1]]
Reduces using max

    max(max([1, 2, 3], [3, 2, 1]), [1, 1, 1])
giving

    [3, 2, 3]
and encodes it in base 10, giving 323.

So that's it. Nice standard library, awful syntax.

I think that OP's "epiphany" was finding a quirk in this esoteric language to counteract another quirk.

Anyway, having satisfied my curiosity, I'm going to promptly forget everything about this :)

[+] thelastparadise|2 years ago|reply
> dismal ← 10⊥(⌈/10⊥⍣¯1⊢)

And what does that look like in C?

[+] i_am_a_peasant|2 years ago|reply
Seems like a maintenance nightmare tbh. You have to jump at a billion functions definitions before you can hope to understand what a program is doing.

I could see myself transforming a functional program into a procedual one as I try to understand it just so I can have all information just in front of me and not have to keep things in my head.

Like I get that a functional style helps with the mathematical correctness side of things. But I'm just one of those people who still hold "code is meant for humans" first.

[+] epgui|2 years ago|reply
> You have to jump at a billion functions definitions before you can hope to understand what a program is doing

On the contrary, if you assume that your functions are good abstractions then you shouldn’t need to know their implementation details in order to compose them. You can tell what it does by looking at just what you have in front of you.

If that’s not the case, then you’re not looking at a good example of this concept. And we all know we can find bad examples of any idea or paradigm— what is useful is the good examples.

[+] teleforce|2 years ago|reply
The first time I've read about tacit programming is in one of the blog posts on oilshell [1].

Really like the idea and apparently one of my favorite features of UNIX is the pipe facility, and it's actually a form of tacit programming. This makes a lot of sense since the OS itself is already full of ready made functions that can be exposed through the API [2].

It's also interesting to note that most of the modern programming languages for examples Python, Ruby, Perl, and JavaScript are missing or have awkward support of this feature.

[1] Pipelines Support Vectorized, Point-Free, and Imperative Style:

https://www.oilshell.org/blog/2017/01/15.html

[2] The Linux Programming Interface:

https://man7.org/tlpi/

[+] BenGoldberg|2 years ago|reply
You consider Perl's map and grep to be awkward?
[+] bgribble|2 years ago|reply
The graphical dataflow languages that are popular for music and audio programming (thinking Pure Data, Max/MSP, Reaktor etc) do this in a way that makes the value of "namelessness" a bit more clear. When you connect the output of one unit to the input of another unit, there's no need to name that linkage, but it's still perfectly clear what's flowing over it.

I really enjoy trying to think through programming problems in a dataflow style like this. It often turns the problem inside out in an interesting way. I did about half of this year's Advent of Code problems in such a system and it was a blast.

[+] Etheryte|2 years ago|reply
Have you tried visual programming, e.g. Labview and the like?
[+] jlouis|2 years ago|reply
This technique is highly useful in moderation, provided you are working in a language where the syntax/semantics affords it. It can certainly be overdone to the point where the code becomes harder to decipher for a human reader.

A key step is that programming languages which use the style builds a vocabulary of commonly used operations and make these into general knowledge for programmers using the language. Thus, succinctness is obtained and you can compress hundreds of lines into a few. Flip side is a steeper learning curve, which has to be balanced against.

[+] DougBTX|2 years ago|reply
The broader idea of “pass a value between functions without naming it in the caller” crops up in a few other places outside FP.

In Rust there is the “builder pattern”[0] where the builder isn’t mentioned directly:

    ByValueBuilder::new()
        .with_favorite_number(42)
        .with_favorite_programming_language("Rust")
        .build()
In OO land they are called “fluent interfaces”[1], commonly used when building SQL queries while only mentioning the final query at the end:

    query = translations
 .Where(t => t.Key.Contains("a"))
 .OrderBy(t => t.Value.Length)
 .Select(t => t.Value.ToUpper());
Especially in the query builder example, since it is essentially building up an AST, the type of the top-level object can change in each call, but since it isn’t named it also doesn’t need to be typed, so there’s no issue with variable shadowing etc.

[0] https://blog.logrocket.com/build-rust-api-builder-pattern/ [1] https://en.m.wikipedia.org/wiki/Fluent_interface

[+] mike_hock|2 years ago|reply
The framing of the builder pattern as primarily a Rust feature, and fluent interfaces as primarily an OO feature, is a bit odd.

A prime example of non-builder fluent interfaces are containers in Rust.

Both builders and fluent interfaces exist pretty much in all languages that can support chaining methods on some sort of object. This includes OO languages as well as totally-not-OO languages like Rust that just stop short of calling their classes "classes."

[+] layer8|2 years ago|reply
The builder pattern [0] is orthogonal to fluent interfaces. It has merely become customary for builders to have a fluent interface. The defining feature of the builder pattern is that you don’t set configurable properties on an object itself, but that you use a separate object (the builder) to set the properties on, and then have it build the final object (on which the respective properties are then usually immutable). It’s immaterial whether the builder object has a fluent interface or not. The important thing is that the builder interface is separated from the resulting object’s interface.

[0] https://en.wikipedia.org/wiki/Builder_pattern

Both builders and fluent interfaces have little to do with point-free style. X.foo().bar().baz() is not any more point-free than baz(bar(foo(x))).

[+] lgas|2 years ago|reply
My favorite resource on Tacit programming is Point Free or Die: https://www.youtube.com/watch?v=seVSlKazsNk
[+] barbarum|2 years ago|reply
I didn't agree on it. It's like stating the principle of procreation and someone quoting a pornstar, and now we name it James Deen. Hope this helps, mrn.
[+] ssnri|2 years ago|reply
Oh, I’m huge on doing the opposite of this.

When I write JS (not TS), I prefer as much as possible to use destructuring in every function definition. Named keyword arguments.

Try to change the name of something as little as possible even as it gets passed around. It’s more powerful than a type system in some ways. Not always doable, but just try it sometime; it’s fun.

I call it nominative programming.

[+] valty|2 years ago|reply
I feel like JS needs auto-currying.

    function greet(greeting, name) {
       return `${greeting} ${name}`
    }

    const greetWithGreetingHello = greet('hello')
    greetWithGreetingHello('sally')
And this should work with named arguments too.

    function greet({greeting, name}) {
        ...
    }

    const greetWithGreetingHello = greet({greeting: 'hello'})
    greetWithGreetingHello({name: 'sally'})
With named params, curried functions can be read more naturally too.

greetWithGreetingHelloAndWith({name: 'sally'}).

"and with name sally"

[+] notemaker|2 years ago|reply
I've never found this style readable unless with pipes (bash / elixir), where I love it. With any other syntax, I find it just adds mental overhead. Maybe because you have to read it backwards?
[+] ccorcos|2 years ago|reply
JavaScript is great for point-free programming! Make sure you check out Ramda.js https://ramdajs.com/

It’s fun in the sense that solving a puzzle is fun, but I avoid it for anything I need to maintain long-term.

But it’s good practice for understanding combinators which is useful for some kinds of problems.

[+] avindroth|2 years ago|reply
Tacit programming + llm control library (like LangChain) has a lot of untapped potential. Tacit programming shines when the control structures are simple (and functions/variables can be anonymized), and these patterns occur frequently in programming on top of llms.
[+] ngcazz|2 years ago|reply
Love point-free. It's definitely a muscle you need to keep in shape, and although it's definitely not for everybody, if you design your API with this in mind it can be a lot of fun to program in this style. I think TidalCycles is a nice example of such an API (for the most part)
[+] AndyKluger|2 years ago|reply
If this interests anyone who hasn't played with it yet, I highly recommend trying out Factor. For me, it makes programming fun again.

Some good practice problems can be found on Advent of Code, Codewars, and the Perl Weekly Challenge.