Simple Is Not Small
Posted by zdw 5 days ago
Comments
Comment by getnormality 1 day ago
MIT/Stanford:
> Simplicity -- the design must be simple, both in implementation and interface. It is more important for the interface to be simple than the implementation.
New Jersey:
> Simplicity -- the design must be simple, both in implementation and interface. It is more important for the implementation to be simple than the interface. Simplicity is the most important consideration in a design.
TFA maps "simplicity" to "MIT/Stanford simplicity" (simplicity for the user) and "smallness" to "New Jersey simplicity" (simplicity for the developer).
I wonder if the root of the tension between the two schools comes down to the ambiguity of the user/developer distinction. Developers are also users. Simplicity of implementation is helpful to developers when they are working directly on implementation, while simplicity of interface is helpful to developers when they are using other developers' work.
Comment by qu4z-2 1 day ago
Comment by mitxela 1 day ago
Sorry, I meant Microsoft Copilot 365.
Comment by qbane 1 day ago
Comment by mitxela 1 day ago
Now consider Microsoft Word.
Comment by otabdeveloper4 22 hours ago
Excel is great, but it's an arcane beast rivaled only by Emacs configs. People pass down Excel formulas by word of mouth like they're magic spells.
Comment by qbane 14 hours ago
Comment by pjmlp 22 hours ago
From the authors, Charles Simonyi went to Microsoft as one of the original Word implementers, while Excel was born on Mac OS and only later ported to Windows.
Comment by ux266478 1 day ago
Another interesting irony I'll note, Lisp is the "New Jersey approach" towards symbolic AI. Americans clinging to their Lisp systems were deeply entrenched in a "worse-is-better" mindset. Your interface, the computational model, didn't need to be designed for logic programming, that was wholly secondary. Do everything as much as possible in Lisp, and then offload the relational description to a small (not simple) library. American knowledge engineers were looked at as overpaid procedural hackers with zero mathematical elegance and very little credibility. More or less the same perception these self-same Lisp-machine users had for Unix and C programmers.
It's all about perspective, at the end of the day. Where we draw the line in the sand on these categories is free-to-choose, yet it also determines everything. We're always someone else's villain under different semantics.
Comment by Snarwin 1 day ago
One of the core features of the Unix command-line is that it is user-extensible. If there's no "native" command equivalent to frequencies, you can write your own, and it will be given the same first-class treatment as any other binary in your PATH. This is entirely in keeping with the Unix philosophy of simple implementations.
Comment by diegocg 1 day ago
OK but how would it look like if you had such a program? Shells are not known for having the extensive set of functions that real programming languages have.
Comment by unscaled 1 day ago
But Unix pipelines are not simple too. They have a couple of nitty-gritty details that often come out and bite you.
1. They can only stream raw bytes, so all the programs that deal with lists like sort and uniq have to separate items using a delimiter (usually newline). If you want to process data with that delimiter in it, you're in for a ride. And if you want to write a custom tool, you have to do all the splitting yourselves (luckily it's so common most programming language will provide a ready-made facility for you to do that). This is New Jersey approach again: "I'll make my code (the OS, the shell) easier to write, and in return make life harder for my users (the tool writers)".
2. Error are hidden by default in shell. Nowadays you can explicitly change this behavior, but you have to remember to do `set -o pipefail` and I don't think it was always there.
3. There is no data typing at all. Everything is binary or text. Nowadays a lot of programs just output JSON, and the users (if they even stay inside the shell) almost always reach for jq to parse it. But jq is not a Unix philosophy program: it's an entire streaming functional programing that can do quite a lot. But even jq gets hairy when you have to do a bigger query or transformation. In that case users often reach out to Python or another language and just move the business logic there.
I think this fits well with what the article is trying to say: Unix pipes are pretty easy (small) to implement on your own (compare that to something like Nushell's pipes). But the moment you to do something that's a little different than the happy path it was built for, you need to go for another tool (jq) that has its own built-in pipe and small programming language, because Unix pipes won't cut it. And for more complex (hehe) things, you'll have to reach for a larger (and simpler) tool: a full-fledged programming language.
Comment by wredcoll 1 day ago
I'm not sure if you're actually trying to argue for a specific position here, but you highlight several negative results of one "philosophy" of development, with the implication that the alternative wouldn't have those negatives.
This is a tricky point to refute because you're right, these are flaws and there could be a system that doesn't have them.
So why do these flawed systems exist and proliferate?
Because the real life result isn't actually a choice between "sloppy but quick to develop" and "elegant well engineered but slow".
The choice is actually between "sloppy but exists" and, well, nothing, because the other version never actually materializes.
(And of course, I feel compelled to point out that bash is a user interface not a programming language. Any attempt to replace it or improve it without focusing on that main point is doomed to failure, which is why you see so many people who apparently think that what bash really needs is strict type checking or something and end up creating a completely awful user experience)
Comment by ChrisSD 1 day ago
That said having key/value semantics and not just stream of bytes would make the shell much more versatile, at the cost of making it bigger.
Comment by Snarwin 1 day ago
All of this is completely correct, but it has nothing to do with bash vs. Clojure. The same decoupling can easily be achieved in either language.
Comment by atiedebee 1 day ago
awk '{ freq[$0] += 1 } END { for (n in freq ){ print freq[n] " " n } }' < file.txt
Comment by pjmlp 22 hours ago
Comment by stianhoiland 1 day ago
Honestly, so much of our conceptions of "what's wrong" is more to do with lack of familiarity with history than any actually unsolved problem.
Comment by hankbond 5 days ago
I am currently building a piece of very modular software and it has been the hardest-to-design project of my entire career. I would never be allotted this amount of time-effort at any job I have held to make something this robust and clearly defined. Many aspects of this project have taken 3-5 rounds trying-and-trashing to get an abstraction that is uncomplicated.
This is precisely why vibe coding is so successful for building tiny isolated scripts, and so disastrous for anything else. It's just really dang hard to build something large and simple.
Comment by Someone 19 hours ago
Brooks, in “the Mythical Man-Month”:
“One occasionally reads newspaper accounts of how two programmers in a remodeled garage have built an important program that surpasses the best efforts of large teams. And every programmer is prepared to believe such tales, for he knows that he could build any program much faster than the 1000 statements/year reported for industrial teams.
Why then have not all industrial programming teams been replaced by dedicated garage duos? One must look at what is being produced.
In the upper left of Fig. 1.1 is a program. It is complete in itself, ready to be run by the author on the system on which it was developed. That is the thing commonly produced in garages, and that is the object the individual programmer uses in estimating productivity.
There are two ways a program can be converted into a more useful, but more costly, object. These two ways are represented by the boundaries in the diagram.
Moving down across the horizontal boundary, a program becomes a programming product. This is a program that can be run, tested, repaired, and extended by anybody. It is usable in many operating environments, for many sets of data. […] As a rule of thumb, I estimate that a programming product costs at least three times as much as a debugged program with the same function.
Moving across the vertical boundary, a program becomes a component in a programming system. This is a collection of interacting programs, coordinated in function and disciplined in format, so that the assemblage constitutes an entire facility for large tasks. To become a programming system component, a program must be written so that every input and output conforms in syntax and semantics with precisely defined interfaces. The program must also be designed so that it uses only a prescribed budget of resources — memory space, input-output devices, computer time. Finally, the program must be tested with other system components, in all expected combinations. […] A programming system component costs at least three times as much as a stand-alone program of the same function. The cost may be greater if the system has many components.
In the lower right-hand corner of Fig. 1.1 stands the programming systems product. This differs from the simple program in all of the above ways. It costs nine times as much. But it is the truly useful object, the intended product of most system programming efforts.”
Comment by robertlagrant 15 hours ago
Comment by mcr70 1 day ago
Bottom line is probably true, but if you are an open-source maintainer mentioned, and you have only so few hours to spend, you just cannot create those gigantic softwares either. You need to choose from the cards on your hand.
Comment by Twey 1 day ago
> Clojure decouples data representations from type checking.
This is funny to me because seen from the other side, (this) Clojure couples runtime type information to data structures: you're no longer allowed to define a data structure that doesn't have some runtime type information attached. A fixed static structure is just the consequence of not adding dynamic type information.
Meanwhile in Rust you can get type-checking ‘without’ a fixed structure by using trait objects.
Comment by weavejester 1 day ago
Comment by Twey 18 hours ago
Comment by weavejester 14 hours ago
In this sense, most statically-typed languages conflate how data is structured with how it is restricted. Some overlap is unavoidable, as anything represented by a single byte is always going to be restricted to at most 256 values, but Clojure tends to take the view that the more decoupling (or decomplecting) you can achieve the better.
This can be useful when dealing with data that is in some sense invalid. You might receive data that's outside expected bounds or even of a different type, and it might make sense to handle it in some fashion. This is a common necessity in pharmaceutical trials, for example.
Comment by Twey 10 hours ago
> In this sense, most statically-typed languages conflate how data is structured with how it is restricted.
Most statically-typed languages are actually very loose about how values are structured at runtime, leaving it mostly up to the implementation (e.g. see C++ padding and field reordering, or Haskell's autoboxing, which makes approximately no guarantees about what's behind the pointer — usually some graph-rewriting metadata). Where the conflation does exist is that a lot of systems languages allow you to write and typecheck code that assumes something about the language's representation of the type's values (e.g. that you can take the address of a field of a struct and later dereference it), though you can usually opt out of that with PIMPL or a trait object or something. But guaranteeing (and allowing the programmer to rely on the guarantee) that every value's representation also carries a bunch of additional runtime information is a much stronger version of that coupling.
> This can be useful when dealing with data that is in some sense invalid. You might receive data that's outside expected bounds or even of a different type, and it might make sense to handle it in some fashion.
The very fact that you can handle that data at all means that the value carries additional type information that allows you to do so. It's only ‘decoupled’ from the type in the sense that you didn't have to write it there, because it's automatically coupled to every value representable in the language regardless of what type you give it.
> This is a common necessity in pharmaceutical trials, for example.
I'll have to do some guesswork here, but I imagine when people make arguments like this they are significantly imagining a situation in which, say, all the values are expected to be in the range [5, 100] and some befuddled experimenter or piece of machinery gives you the value 2. A-ha, you say: I know sometimes the equipment undermeasures near the bottom of its range, so I'll clamp this value to 5!
This isn't really a type error. The fact you know you can safely do that means that the data is really typed in a different range than you said — but it's still typed. The conceptual type (even if you never write it down) is inherent in the very fact that you can somehow handle it: you know what to do with values down to 2 so the real type of supported inputs is at least [2, 100] (with some special semantics for the low values beyond that of being numbers).
A real type error looks like: you're expecting values in [5, 100] and then one of the values actually turns out to be the concept of intellectual honesty. That type doesn't support ~any of the same operations as the numbers you were expecting, even with the extended domain that more accurately reflects the set of values you can really accept. Even discarding it might have disastrous results for your experiment! In fact, the concept of intellectual honesty doesn't even have a good discriminator: while I know that's what you got because I'm the rascal who snuck it in there, you have no idea what it is, and no way of finding out. Most likely you're going to try to compare it to 5 to see if it needs to be clamped, with unpleasant consequences for us all.
Comment by weavejester 8 hours ago
I think you're using 'value' to mean something slightly different to the way I meant it. So to get us onto the same page, by 'value', I mean something independent of how its stored; that is, the number 1 is a value, whether it's stored as as 00000001 or 0000000000000001.
A type limits both which values it is possible to represent (i.e. a u8 limits us to representing the integers 0 to 255), and also determines how it is encoded in memory (in this case 8 bits).
> Most statically-typed languages are actually very loose about how values are structured at runtime
Yes, but ultimately the compiler needs to be able to map a sequence of bits to the value it represents, even if there's not a strict one-to-one mapping.
> I'll have to do some guesswork here, but I imagine when people make arguments like this they are significantly imagining a situation in which, say, all the values are expected to be in the range [5, 100] and some befuddled experimenter or piece of machinery gives you the value 2
That's a common assumption, but the reality can be much more messy. It might be that we expect an integer between 5 and 100, but receive a string of UTF-8 characters instead.
For example, suppose you want to record a patient's date of birth. In Clojure, we might represent that as a map that connects a :patient/birthdate key with an encoded date object:
{:patient/birthdate #date "1972-04-08"}
But what if the patient doesn't know their exact birthdate? Perhaps they immigrated when they were a young child from a less developed country and don't know the exact year they were born in. However, they tell the doctor that they do know they were born before 1980, because that's when they first arrived in the country.In this case, the doctor might record the data as:
{:patient/birthdate "before 1980"}
Even though the data doesn't match the type we expect (a date), it's important that it still be recorded as it could affect the medicine that the patient is given. These sorts of messy entries are not uncommon in areas where it's more important to accurately record the data than precisely type it.Comment by Twey 4 hours ago
I'm using it in its most general sense: as an object of discourse in a programming language, independent of representation or semantics. 1 is a value in most languages, to be sure, but it's also specific type of value, viz. a number: you can do number things to it, like add it or divide it, or check it for equality with 2.
> A type limits both which values it is possible to represent (i.e. a u8 limits us to representing the integers 0 to 255), and also determines how it is encoded in memory (in this case 8 bits).
To reiterate: a type doesn't limit the values but specifies the values (or rather, more generally, the meaningful operations on a value of that type). Values are not numbers by default; only by being typed as a number does a value take on number semantics. Without the knowledge that a value is a number it is meaningless to treat it as a number.
> Yes, but ultimately the compiler needs to be able to map a sequence of bits to the value it represents
Sure; in any language implementation you have to represent the values somehow, nobody could disagree. My point is that the type doesn't (necessarily) specify that representation in any language I can think of.
> That's a common assumption, but the reality can be much more messy. It might be that we expect an integer between 5 and 100, but receive a string of UTF-8 characters instead.
Sure: that's not fundamentally different from the first example I gave. The real type of `:patient/birthdate` there is just the (discriminated) union of the date type and the string type. It still has a type, and if it didn't you wouldn't be able to process it (definitionally, because a type tells you what kind of processing makes sense for the value). And the string values aren't ‘outside’ the type: even if you choose to write the wrong type down in your Clojure, the fact that you also process strings means that you know the real type (and you embed that knowledge into the code).
Comment by weavejester 1 hour ago
But a value can have more than one possible type. The number 1 could come from an unsigned byte, or a signed long, for example. These are different types that support the same numerical operations, but differ in cardinality. So we can't say that a type's only purpose is to specify the meaningful operations on a value, as we might have two types that are identical in that regard.
> The real type of `:patient/birthdate` there is just the (discriminated) union of the date type and the string type.
Yes, in this particular instance that would be the case, but that's not necessarily something you know ahead of time. The point is that you may not have anticipated that not everyone would know their date of birth, and the data you receive is invalid according to your earlier assumptions.
In Clojure this results in a more graceful failure condition. Functions that don't require the date of birth will continue to work with no change required. If I want the average white blood cell count of a patient, I don't care what the date of birth is, and therefore the output for that particular operation isn't affected.
Comment by stephenlf 1 day ago
Comment by cryptonector 1 day ago
Comment by embedding-shape 1 day ago
Expectedly, Rich Hickey explains it best, watch the "Simple Made Easy" talk if you haven't before, one of the few talks I probably watch bi-yearly: https://www.youtube.com/watch?v=SxdOUGdseq4
Few things, concepts and ideas have changed as much of my programming mind as Hickey's talk and ultimately Clojure have done over the years.
Wish we had new amazing Hickey talks to link to, maybe it seems he's about the hang up the hammock perhaps?
Comment by simongray 1 day ago
Comment by Gehinnn 1 day ago
Copy pasted functions with subtle changes mean you cannot reuse the proof (DRY). Giant functions with lots of if/else statements however might cause a branch explosion in the proof. The right abstraction removes lots of assumptions that a proof could depend on, limiting the search space and often forcing elegance (this also applies to math, eg. when reasoning with abstract groups instead of integers). The wrong abstraction might force case distinctions on consumers of the abstraction.
Comment by fnord123 23 hours ago
It sounds like a reasonable concept, but then Principia Mathematica takes 300 pages to prove that 1+1 is 2.
Comment by andai 1 day ago
This specific usage appears to come from this linked talk, Simple Made Easy:
https://www.youtube.com/watch?v=SxdOUGdseq4
My reaction to the Unix pipeline was that, the reason it exploded in complexity is because the pieces were too simple. They were insufficiently expressive.
But the word is used in a different way here, and I'll have to watch the talk to understand what exactly is meant. (Something like orthogonality?)
Comment by JackFr 1 day ago
Comment by stianhoiland 1 day ago
Comment by Barrin92 23 hours ago
the reason it exploded in complexity is because the complexity is in the interaction of the parts, not the properties of the parts. It's not so much about expressiveness but about the fact that the Unix philosophy has a willful disregard for systems thinking.
Russ Ackhoff has the analogy of the house. How do you design rooms in a house? Only ever with the house in mind. You can't just hope the rooms work, you always need to check whether a new room improves the house, if somethings wrong you fix the room, not the house.
Unix tools have a disregard for the house. Clojure works well because every part of Clojure is designed not just to improve itself but to improve Clojure. Emacs is similar in that regard, you can see that when people make Emacs packages. The good ones always have Emacs in mind, not just their own functionality.
Comment by rhelz 1 day ago
And it is important not to just make snappy quips by equivocating.
Comment by jerf 1 day ago
It is especially dangerous when something is "good" and people try to appropriate the term to appropriate the goodness of the term, as if goodness flows from a term to the thing it is attached to rather than the other way around. "Simple" is good so my good thing must be "simple" to be "good". But it doesn't. Simple can even be bad, in the wrong place or in the wrong sort of "simple" for a given job.
Comment by jonahx 1 day ago
Because familiarity is a confound for intuition about complexity, even this is not always true.
Maxwell's equations will look complex to the uninitiated, and can represent the pinnacle of simplicity to those who already understand them.
Comment by BenoitEssiambre 1 day ago
Comment by basilikum 1 day ago
Comment by jerf 1 day ago
"Few tokens" - perhaps the most literal simplicity, literally, it doesn't use many tokens to do the job. But as the article points out, that doesn't necessarily fit with...
"Easy to reuse" - This is that simplicity that functional programming aspires to, where you craft some precise abstraction that somehow captures something like "monad". Haskell is full of this sort of simplicity, oozing out of every pore, but people generally think of it as a very complex and hard langauge, contrasting...
"Easy to understand" - As in, not cognitively complex. It is amazing how quickly things that I would otherwise describe as very simple still blow out our little minds. Consider the first time you saw quicksort... or even how it feels now. It's not a lot of tokens, but it's twisty and recursive and especially if you're not mathematically trained and in practice it's easy to call it more "complicated" than a CRUD form that takes in and validates 10 parameters in a straightforward way, even though in terms of what is actually happening the CRUD form may be doing vastly more than the little quicksort algorithm. It just isn't being twisty, recursive, and subtle in how it does it.
And I'm just filling out the first three that come to mind. Note these are not always in conflict by any means... but they certainly aren't always in harmony with each other either.
(One might argue "easy to reuse" is more about the complexity of the code doing the reusing, but I feel like this is definitely something people mean when they talk about the simplicity of code.)
Comment by cush 1 day ago
These concepts simplify for the user these questions: “I know what I want - how do I make the program do it?” (Execution) and “The program did something — what state is it actually in?” (Evaluation)
I believe the author was getting at these concepts, especially in their Google Drive example - how the large program has a “small” UX. Understanding the domain model provides a much stronger basis for designing user interfaces, and understanding the Gulf of Evaluation/Execution allows you to build incredibly complex-looking, large UX’s without confusing or overwhelming the user.
Comment by aghuang 1 day ago
Comment by sodapopcan 1 day ago
Comment by archargelod 1 day ago
Comment by zkmon 1 day ago
A single regex line could be far more complex than a 100-line java program.
Comment by lilli_put 1 day ago
grep -Eo '[A-Za-z]+' README.md \
| tr A-Z a-z \
| {
distinct=()
declare -A freqs
while read word; do
[[ -v freqs["$word"] ]] || distinct+=("$word")
((freqs["$word"]++))
done
for word in "${distinct[@]}"; do
echo "${freqs["$word"]}" "$word"
done
}
Used grep here because the original pipeline erroneously introduces an empty line when the file starts with a character that doesn't match '[:alpha:]'.Comment by MathMonkeyMan 1 day ago
The clojure expression reads the entire file into memory first, and then operates on that memory representation.
The pipeline processes the input in chunks. The two `sort`s might read the entire contents into memory, but an implementation like GNU sort will instead, for large inputs, create temporary files for sections of the input and then merge sort them to the output.
You could make clojure do the same thing, but it might not be as "simple" as the pipeline.
Comment by stianhoiland 1 day ago
> Now, let's say we want to make a small change: show the output in the original file order. In Clojure, this is fairly straightforward: store an ordered sequence of the words in word_seq, store a map from each word to its frequency in freq_map, iterate over the sequence, and look up each word in the map:
Emphasis on just "storing" something. The author seems to not understand that this change makes the solutions categorically different.
It's a little like critiquing the efficiency of moving a piano up a stairwell by saying why didn't they just use a crane via the window? Using a crane isn't a better way of getting a piano up the stairwell.
Comment by zahlman 19 hours ago
Comment by pianopatrick 1 day ago
Comment by embedding-shape 1 day ago
FWIW, I set hard limits to 200 LOC for every single source code file in any AI-related projects, also with restrictions on "formatting hacks" and other golf-like stuff.
I think beyond 5000 LOC in a single file and all available models already get lost frequently, even if the "context limit" theoretically is way above that. Maybe aim for like 1K LOC at max unless you want to have lots of misunderstandings.
Comment by zahlman 19 hours ago
I already preferred to keep source code files under about 200 LOC, so.
Comment by JoachimSchipper 1 day ago
tr < README.md --complement --squeeze-repeats '[:alpha:]' '\n' \
| tr A-Z a-z \
| nl \
| sort --key=2,2 --key=1,1n \
| uniq --skip-fields=1 --count \
| sort --key=2,2n \
| awk '{ print $3, $1 }'
(Where the final awk papers over the fact that we're mixing tabs and spaces here; obviously, awk is also good at doing the accumulation step, but uniq --count suffices here.)(I originally posted the above as a comment on lobste.rs, on this same article.)
Comment by FattiMei 1 day ago
It is nevertheless "complecting": the uniq assumes the data is sorted and the columns of your data structure move together. Maybe this algorithm is already complex regardless of the implementation.
btw, this paradigm reminds me of APL
Comment by JoachimSchipper 1 day ago
In detail, for input "foo bar FOO qux FOO foo", we convert to
[1 foo, 2 bar, 3 foo, 4 qux, 5 foo, 6 foo]
(with newlines instead of commas, obviously), then sort by word (then line number) to
[2 bar, 1 foo, 3 foo, 5 foo, 6 foo, 4 qux]
at which point the uniq invocation gives <count> <first_line> <word>, i.e.
[1 2 bar, 4 1 foo, 1 4 qux]
albeit with an ugly mix of tabs and spaces. One final sort by <first_line> gives us
[4 1 foo, 1 2 bar, 1 4 qux]
and then it's just a matter of formatting the output:
[foo 4, bar 1, qux 1]
The generally-useful point is that the classic shell utilities really do work pretty well if you're operating within their paradigm, which isn't "throw everything in a hash table". (That's the paradigm of later scripting languages.)
Comment by aozgaa 1 day ago
In contrast to the "we need a frequency table" idea in the article, this solution trades off memory by transferring all the line numbers in the stream. This is very much in the spirit of the infamous McIlroy/Knuth "bakeoff"[1] -- tradeoff some efficiency (via extra book-keeping or sorts) in return for composability.
Agreed, very neat.
[1] https://homepages.cwi.nl/~storm/teaching/reader/BentleyEtAl8...
Comment by StilesCrisis 1 day ago
Comment by fwlr 1 day ago
(For what it’s worth, the point of both this essay and the aforementioned talk is that programs do not have to get complex, even when they get large, even when it gets hard because there are no more easy / close-at-hand / familiar helpers in the language to tackle the domain specifics. In support of this point I will note that Rich Hickey is the creator of Clojure, a language in which “building domain-specific helpers yourself” is very nearly idiomatic.)
Comment by zahlman 19 hours ago
https://news.ycombinator.com/item?id=49600275 is a great illustration of how to use Bash's "means of combination" more effectively than what OP thought of doing. But notice that it still relies on decorate-sort-undecorate for keyed sorting, and not only that but the undecoration has to be deferred until after `uniq` has made use of it, in a way that magically collapses the first decoration into something that can be used for a second sort, while adding a second decoration (the count). And then it still needs postprocessing because you can't express control over the structure of the decoration (there isn't really any, it's just string concatenation, but I mean you don't even control the order in which the pieces are concatenated).
Comment by dasil003 1 day ago
But where I disagree is the conclusion that unix pipelines are not simple. IMHO unix pipelines as a platform are incredibly simple and powerful, allowing for solving a massive range of small problems much more elegantly than any general purpose programming language. Obviously the constraints that enable this simplicity at the low-end, are real tradeoffs that prevent simplicity at the high-end. But one of the core principles of effective engineering is do the minimum to solve the problem at hand, no more, no less.
Comment by cat-whisperer 1 day ago
Logic that the blog presents is sound; but sometimes small works as a good-enough proxy to get to the simplicity that we find elegant.
Comment by rickcarlino 1 day ago
I like this question. Some projects will sacrifice usefulness in the name of simplicity.
Comment by leecommamichael 1 day ago
Comment by wnoise 1 day ago
Comment by boomlinde 21 hours ago
I don't see how the example would benefit at all from having that information at run-time when it exists at compile-time. The problem with Rust specifically I think is that it's a relative PITA to get to the information that exists at compile-time in this case. In Zig, you'd just iterate over the fields using the same language you use for run-time code.
const fields = std.meta.fields(@TypeOf(instance));
inline for (fields) |field| {
const name = field.name;
const value = @field(instance, name);
std.debug.print("{s}: {any}", .{name, value};
}
The combination of high dynamicity at compile-time with low-effort switching between compile-time and run-time contexts makes this not too bad.Comment by aozgaa 1 day ago
< README.md \
tr -c '[:alpha:]' '\n' \
| tr '[:upper:]' '[:lower:]' \
| awk '
NF {
if (!($0 in count)) order[++n] = $0
count[$0]++
}
END {
for (i = 1; i <= n; i++) {
print count[order[i]], order[i]
}
}
'
If you don't allow `awk` in your "pure bash" then ofc this is not satisfactory. But it has the upside that the associative arrays are pretty explicit data structures (for the ordering and counts, respectively).Comment by sgarland 1 day ago
{
$0 = tolower($0)
gsub(/[^[:alpha:]]/, "\n")
for (i = 1; i <= NF; i++) {
if (!($i in freq)) order[++n] = $i
freq[$i]++
}
} END {
for (i = 1; i <= n; i++)
printf "%d %s\n", freq[order[i]], order[i]
}Comment by JoachimSchipper 1 day ago
Yes, associative arrays work well. I think it should even be possible to use bash associative arrays. But at that point you're no longer doing classic sh - awk is basically halfway to Perl. (And pretty awesome.)
Comment by somat 1 day ago
Without processes shell is just a weird sad little language, with them it turns into this epic data flow language. With some real design stinkers, Most of these are due to it's interactive first focus, The features desirable for interactive use, often start to stink for stored program use, I will note that having the same language for interactive and scripting is pretty kick ass.
On the subject of dataflow languages has there been any research in this area? Something that can stitch together processes as well or better than shell? Perl may work in this role but I have to admit I really dislike it's syntax, and as such I never learned Perl enough to love it. and while most other scripting languages can technicaly create pipelines, it is very awkward compared to shell.
Comment by zahlman 19 hours ago
I've been working on a design for a Python library to facilitate this sort of thing.
Comment by adityaathalye 18 hours ago
The Unix pipeline and the similar-looking Clojure expression are entirely different in how they do what they do. Pipes are process abstractions. The (perniciously improperly understood) "pipeline" macro is not pipelining anything in any way (not CPU nor process nor memory). It is merely syntax sugar to write a deeply nested call chain as a "flat" list of operations.
And it goes on to compare "ugly" code, again making the category error that "more" code is "worse". Because, one is swapping / splicing entire programs within a pipeline. Also, there are plenty of ways to slice that mango; you don't need temp files.
That said, having intermediate files in one's pipeline is a big help because one can use those to make pipelines idempotent. Plus, one doesn't need flat files, one could swap in a SQLite cache too, at will, without modifying anything else in the pipeline. This kind of design change is not possible with the equivalent Clojure function call chain, as-is. In fact, having such a requirement (one always finds need to restart processes after crashes, and have them pick up from where they died) causes us to write some custom (and therefore design-wise brittle) conditional restart loop on top of the computation to manage its failures inside the running program. An idempotent Unix pipeline simply needs to be... restarted from outside the process.
In this particular case, a valid complexity complaint would be about the lacunae of the Bash / shell programming language (and interpreter model) versus the Clojure language.
Also, again from a program design point of view, I feel there's a bit of "holding it wrong" going on there... Bash / Shell-fu, yes, but not enough to be dangerous (for example, sort | uniq | sort is a standard idiom of pipeline programming). Which claim is personal, and so I'm open to being corrected at the same level. Sources: my code and writing:
https://www.evalapply.org/tags/bash/
https://www.evalapply.org/tags/clojure/
https://github.com/adityaathalye (the pinned repos are Bash and Clojure)
And specifically, this log processing code, for a more apples-to-apples comparison with OP's post.
https://github.com/adityaathalye/bash-toolkit/blob/master/lo...
deduplicate() {}
frequencies() {}
drop_first_n() {}
drop_last_n() {}
drop_header_footer() {
drop_first_n "${1}" |
drop_last_n "${2}"
}
window_from_to_lines() {}
(edit: some clarifications, and references)Comment by shevy-java 1 day ago
But they are.
UNIX Pipes do not mandate having to use tons of different programs with stupid commandline options. I simulate them in ruby itself; method chaining works in a very similar way, but I built a pseudo pipe around it. The idea was more to have an object oriented shell, e. g. combine good ideas from UNIX pipes and the MS powershell.
They are simple if you design them well and have them be flexible too. The reason UNIX pipes were awkward is because they delegated onto many different programs such as awk or sed with their own strange rules. Nowhere does it say you HAVE to use such awkward tools. Use better tools and the idea of piping becomes simple, similar to (a more flexible variant of) method chaining. Just without being tied down to a specific object per se (I do use the pipe-handler master object to handle the pipe instructions; each pipe instruct I call cmdlet, e. g. shorter for commandlet, as this is how I like to think about this in terms. This also combines e. g. virtualdub + avisynth ideas. I loved them when I used windows. The idea behind avisynth is great - not necessarily all of the syntax, but the idea that all multimedia audio can be operated on at all times in flexible ways.)
Comment by fwlr 1 day ago
Comment by ElectricalUnion 1 day ago
Did you remember to:
- check for the other spawned process exit code?
- waitpid for all process in the other process chain?
- propagate/handle signals, like for example SIGINT/SIGTSTP/SIGPIPE/SIGHUP forward and back signals?
- change stdio buffering mode?
- remember to count how many bytes actually were written by write, and blocking if not, before clobbing the 64kb of the pipe buffer size with another write?
- flush, then close all file descriptors left behind by the pipes when it ends?
It's for reasons like that, that I don't trust anything non-trivial, not-shell to use pipelines correctly.
Comment by adelks 1 day ago
Somehow when things get complex, I could never find fully functional style to be more understandable than imperative
Comment by MathMonkeyMan 1 day ago
Comment by dTal 22 hours ago
Comment by drbig 1 day ago
1. "You need to have taste (so: experience) to do DRY right". Same chunk of code more than once, so natural/expected behavior is to export to a helper... Aaaand the now introduced coupling is (too) often ignored, as in "no thought given whatsoever".
2. "Sometimes it's better to just leave it as is". 95% to 98% of "same chunk of code" in a number of places. The temptation to DRY is strong, yet the "numerically mere 2 to 5 pp" make the extracted helper an exercise in all manners of gymnastics. The only correct answer is: do not start.
(Own experience; your mileage may vary - if it does, feel free to comment back!)
Comment by chrisjj 1 day ago
> That's because our original program was small but not simple.
I would say no - because it was inflexible.
Comment by voidhorse 1 day ago
This is why I think the formalists studying complexity back in the 50s had the right approach. You can only give "simple" precise meaning within some kind of formal system with a shared set of initial axioms or assumptions. From that point you can define it quantitatively over some set of objects (relations, programs).
Funnily enough, this approach also touches on Hickey's etymological derivation. The root words also fundamentally have to do with the quantity of relationships.
Comment by jeanmichelselli 1 day ago
Comment by Anoian 1 day ago
Taking 9 months to fix a bug sounds alarming to me.
Comment by notchdock 1 day ago
Comment by adelks 1 day ago