(Risp (In (Rust) (Lisp)))
stopachka.essay.dev
stopachka.essay.dev
fn tokenize(expr: String) -> Vec<String> {
expr
.replace("(", " ( ")
.replace(")", " ) ")
.split_whitespace()
.map(|x| x.to_string())
.collect()
}
my instinctive reaction is: those .replace() calls will allocate unnecessarily, what you really need is a little state machine, maybe use the nom or parsec crate ....Wanting to do things The Right Way is a good instinct to have as an engineer, and it's something that Rust encourages by design. However, I've noticed that I'll often spend a lot of time getting in the weeds trying to optimize or elegant-ize a bit of code which ends up being unnecessary. I'll take a step back and realize that the performance of that code does not matter, or that the implementation was the wrong approach and I need to delete it all and do something else, or just that it wasn't very important and I should've done the easy solution and moved on.
When I'm in a flow state writing code it's hard to step back and evaluate what I'm working on in the context of the bigger picture; I haven't been successful at training myself to do that. I think a better solution would be to deliberately write "first draft" code that's biased toward being quick and easy to write. When the code is done there's a natural pause to test and review it in the context of the big picture.
Does anybody else struggle with this? What have you done to mitigate it?
In this case, where I see it potentially helping is that the tests give you a very clear, objective way of deciding when the code is done, and it's time to stop futzing with it. Though you would have to be careful about remembering that the "refactor" phase is about refactoring for maintainability, not performance.
I don't know what the tooling situation is like in Rust, but another one is to try and be really scrupulous about never making a change whose purpose is performance without first profiling the code, so that you can objectively measure the impact of the change. You don't need to do this forever, either. But, if your experience is anything like my own, I'm guessing that a lot of what motivates you is the feeling that you've done something of tangible value when you optimize the code. Actually seeing some hard numbers might change your feelings a bit. It's a lot easier to feel good about, "I spent an afternoon making the code more efficient," than it is to feel good about, "I spent an afternoon reducing the time it takes this program to execute from 50ms to 49ms."
This is not at all how it is for me. The execution model of the code is core to how I read programs in languages where I am familiar with their execution model. So reading code that has dirty data or control flow, such as copying an entire array twice to change one character in two passes is as repulsive to me as maintaining and synchronizing two separate functions, one for each character swap.
(By which I mean, there are some corners of the profession where this sort of mindset is very highly valued. Aerospace, some high frequency trading shops, embedded development. Nothing wrong with playing to your strengths.)
For example, if a string data type was added, our tokenize method could not be this simple.
To the actual question, I think one good approach when cutting corners or doing a quicker sub-optimal approach, is to document the limitations and assumptions in some way. You could then evaluate and prioritise you efforts and time from this, rather than getting stuck in the weeds early in the process.
Working code can be iterated on, by multiple people. Non-functioning code cannot. Having a repo in state that can be shown functional by a CICD pipeline is the equivalent of achieving a chain reaction and freezing to death. Light that fire.
- Wasting your time and your company's time.
- More complex code which is harder to maintain in the long term.
did it ever deserve to be called the "right way"?
you're lucky, I don't remember a lot of times where following exactly this process did not lead to having to rewrite it 6 months later because it turns out that some user has some corner case which actually ends up requiring data going through that exact codepath in as little time as possible.
All the time or only in Rust or Lisp? I would guess the compiler can optimize it because it is an XValue.
In theory the compiler could notice that the spaces added to the newly allocated strings are only used to influence the behavior of `split_whitespace` and do not otherwise affect the output of the function. In practice, it does not: https://rust.godbolt.org/z/z573Mvjzz The compiler does not decide to inline the call to `replace` and therefore does not do any special reasoning about it.
First, you want to write code whose style fits in with the team, project, and organization. If other people are writing high-level code chaining method calls like above and not worrying about performance, then you should write your code that way as well. It will fit in fine and no one will notice the performance impact because all the rest of the code is already equally slow.
On the other hand, there are certain architectural decisions that affect performance that can be hard to undo after the fact. Those decisions can be a little harder but it usually boils down to meeting short-term requirements. Unless your boss or org appreciates optimizations and you can sell it on your quarterly review that you saved X dollars due to optimized code, then why bother?
Your optimization may even cause conflict with people who don't like or appreciate it and don't like the extra complexity it might bring to the project. Or, if you ship with a fast-enough solution and the product's popularity grows to the point that scaling becomes hard, you can optimize it and look like a hero! Whereas, if you had optimized it in the first place pre-launch, your work could likely go un-appreciated.
It's a bit cynical of a take, but a useful one to consider, I think.
Probably the biggest help is to work somewhere that it's a productive use of your time to care about the details. Second, getting the details right can steal time from getting broad strokes right. Who cares if you reduce a predictable branch if you are sitting on top of a massive bucketed hash table the serializes all keys to strings by allocating? Fight the battles worth winning.
One of the things that helps is to work in a language with an execution model that I don't understand, e.g. Python or JavaScript.
Getting the interfaces right is not easy, but it's a hell of a lot easier than getting everything right.
I once had a new-to-the-group developer pointing out how things could be some much better if we optimized various code paths (I was them at one point in my career). I returned the next meeting with some benchmarks showing that we spent a total of 100ms in these code paths, in an application that ran for hours.
Your problem might not even be CPU, if it's contention related, or timing related, overloaded queues, not pushing back at the right places, io bound, the bottleneck is work which is queued and executed elsewhere... Causal profiling is a technique which is relevant specifically because profiling can miss the forest for the trees: https://github.com/plasma-umass/coz
It's really easy to write a benchmark which measures a different scenario from what your application is doing. A classic example might be benchmarking a hashmap in a loop when that hashmap is usually used when cold.
I definitely agree about directing efforts to where you can make an impact and guiding that through measurement, but benchmarks can miss that there's a problem and blame the wrong part of the application.
If the difference is large enough, ms vs hours, you'd have to really screw up methodology to get the wrong result (I've done it almost that badly before).
In general, I agree with your first draft idea. Get the thing working before you optimize for whatever is bothering you.
Though, I also find that when I do waste time doing something that doesnt matter, I end up staying engaged longer than if i just stuck to the problem at hand. That is to say, if you're going to procrastinate it's better to procrastinate by writing code.
A cousin of mine, a superb programmer, is C++ fluent. As in able to express any good idea in any other language in C++ efficiently. He is deep in C++.
He knows a poet. They are critical, as he told me, of his ability to understand poetry. When I suggested he show them the lambda calculus he smiled.
We need more poetry in code as much as we need more proof in code.
I know many of us program to build, quickly and efficiently, solutions to problems, and mainly problems defined in terms of business or regulatory requirements.
Nonetheless we need more poetry in code. Terse, referential, contextualised, meaningful code that uses every suitable math technique for proofiness.
We build our future with code. Let's make beautiful code.
In a nutshell: write less read more. Risp is great.
List eaters are an elegant design pattern, especially for picking apart recursive algorithms, but eating linked list wouldn't be nearly as cache-friendly as map/reduce over an array. It wouldn't get rid of all the pointer chasing, but even reducing one level of it is worth something.
Perhaps, with modern language implementation technology, we could produce something like that, while also preserving persistence (at the semantic level), by, in the background, choosing mutation when there's only a single active reference to the list, and falling back to a copy-on-write discipline when there are multiple concurrent references.
For what it's worth, I find I never program that way in Clojure. I realize Clojure not being as list-oriented is a point of contention for many people, and I'm not all that much of a lisper, so perhaps my opinion isn't worth much, but I've generally found that Clojure gives me most of what I want. I am more annoyed by the lack of reader macros. Though I also understand and respect Rich Hickey's decision there.
That doesn't really make sense. cadddr is O(1), and so are all its c[ad]{1,4}r friends.
> For what it's worth, I find I never program that way in Clojure.
I suspect you don't do that because the language doesn't support it well. Which is fine, since going against the grain of a language is generally inadvisable, but it says nothing about the technique in itself.
Nothing about Lisp requires that you write code this way of course, but it's the epitome of what could be called "Lispy" (literally: "list processing language"), and any preexisting code or habits will hit a brick wall if those lists are implemented as Vecs
Though also in Clojure's case vectors aren't really vectors, they're immutable persistent data structures that share memory as much as possible, which I think would actually solve most of the performance problem here. But the same is not true of Rust's Vec<>
This is true if the list is writeable, but, if so surely a Lisp has to also keep duplicating the list or else it will get into trouble?
For reading the list why shouldn't Rust use a slice of the vector? The slice can't own anything, but that's OK, we aren't changing anything. The slice is very cheap, it's basically a pointer into the vector plus a length count.
Nope, it doesn't traditionally duplicate the list. It certainly is possible to get into trouble in your logic, but those are the presented semantics, and debating their virtue is out of scope
> why shouldn't Rust use a slice of the vector?
You're gonna get into ownership-hell if you can't give a separate Rc to each list tail, because those can get passed around wherever
This forces you to confront the reality you'd been dodging. Either you actually mutate this list in your program, and the "magic" of linked lists dissolves when it consumes all your memory, or as seems far more likely you get good performance from the better underlying data structure anyway and the "magic" of linked lists dissolves that way.
You only get good performance from linked lists today on the rare occasion when their lack of data locality is outweighed by some other factor. Sprinkling the Lisp idiom over things doesn't change that.
Here's an example where it's worth it: In highly concurrent systems you can't afford to use any sort of locking to protect data structures, the contention for the locks hurts too much, and you can't afford to reference count everything in those structures because even the contention on the reference counts also costs too much (everything looking at an item is storing to the reference count). So you use Hazard Pointers to avoid prematurely dropping anything. But any type of locking for your Hazard Pointers structure would have too much contention also, so you store the Hazard Pointers in a linked list, new ones can be slotted into place at the start of the list with an atomic compare-exchange. Each CPU core is writing to the Hazard Pointers it "owns" a lot, but they're deliberately too big to share with another CPU's cache, and any CPU cores that need to check the Hazard Pointers read from them all but never write so modern caches cope admirably.
The way those languages chose to handle this stuff has well-trod advantages and disadvantages. I don't love it personally, but I get it. Certainly it's worked well enough for a whole lot of software!
But I'm not commentating on it here. I'm just stating the divergence with the OP. Critiquing Lisp's 40+ year history and what parts of it should or shouldn't have been different is out of scope as far as I'm concerned. You're arguing against something that isn't being stated.
What sort of expectations do you think will be defeated? Beyond the fact that it's only a toy and not, in fact, a Common Lisp or Scheme? Almost everything is missing, but it's a toy. You noticed the Vec but apparently didn't notice the toy language still doesn't even have Cons at the end.
Today losing data locality hurts really badly. Rust does provide a linked list, for the handful of cases where that really is what you wanted, but almost always even though in theory a vector should be worse, in practice it's better.
For example if you build a toy system with a thousand small integers on the list, you might reason that removing the 500th via a vector instead of a linked list would be awful - you'd need to shuffle 500 of them forward compared to just swapping a pointer. But wait, on today's hardware the linked list approach involves about five hundred dependent cache misses. Before you find the 500th on the list to remove it, you have stalled the CPU for so long the vector implementation would have already finished doing its shuffling of 500 items forward in memory and moved on to something else.
Now, if your list has 500 million items in it, and you often want to remove just the first item while appending to the far end, a Vector is indeed a bad fit. But there are still better alternatives than a Linked List, Rust provides a Deque but unlike the one a Lisp-enthusiastic professor may have showed you in data structures class, it's actually a similar structure to a vector not a linked list.
One could absolutely use a Dequeue or some other optimized structure for a Lisp list, really depends on how the code is written.
How? With naked deque nodes? Since otherwise you wouldn't get Lisp lists, you couldn't have structure sharing, etc. And where would the reverse deque node pointer point to if multiple nodes pointed to the node in question?
it seems to me that removing single number from a random position in a list is also not what Lisp lists address, especially since Lisp implementations have vectors, too.
Wish they'd gone with something like (define Risp (in Rust Lisp)) IMHO
Rust for example reads much more like a sequential sentences with the dot notation to chain methods and trait implementations and the like.
Luckily for example in Clojure we use let bindings and threading macros (-> and ->>) to express the sequentiality of code more clearly. But it takes some time to get used to Lisp syntax in general.
Is this "Rust in Lisp" or "Lisp in Rust"?