3 ms·
Thanks for fleshing this out. Before I address the larger point, lemme just mention one thing regarding the Your program increments dst past the end, goes "whoo
by dataflow 5y ago
Thanks for fleshing this out. Before I address the larger point, lemme just mention one thing regarding the Your program increments dst past the end, goes "whoops", then goes back and writes in the NUL byte bit: it's not really going "whoops", it's just (a) copying everything except the null terminator, then (b) tacking on the null-terminator.
The reason I bring this up is that your characterization actually misses the single most important aspect of that code. Notice that in your own implementation, it's not obvious in the last branch whether the buffer will end up null-terminated or not. To infer that, the reader will have to jump around the code while performing the following mental gymnastics to execute the program backwards: (a) !!len == true in the final branch, (b) therefore --len > 0 earlier, (c) therefore that condition was inactive in the loop, (d) therefore the loop terminated because of the other condition (i.e. because dst had a NUL written into it), (e) therefore therefore dst is NUL-terminated because it is not advanced afterward. I'm not saying people can't do this, but it requires people to focus on the code and concentrate to figure it out. It's essentially a puzzle they need to solve, and that's before maintenance. There's no indication to the maintainer that this logic is implicitly there, so you're really just hoping that every step of this multi-step chain stays intact during maintenance. Contrast this with the index-based solution, where it is blatantly obvious that the returned string is NUL-terminated... the return value is &dst[i] and the very line before it literally tells you dst[i - 1] = '\0' when i > 0! So you can verify the most important property of that function by simple inspection.
So now that I have that out of the way, regarding your larger point: what you're saying is essentially "this is a composition of well-understood primitives", i.e. the primitives are
while (--len)
and
while (*dst++ = *src++)
and your brain "parses" them as the high-level operations they are, and then simply reads them as compositions of those high-level operations, rather than as a bunch of pointer manipulation operation.
I guess it explains why you wrote it this way (thanks for that). What I'm saying is that when you force yourself to combine primitives like this, the solution ends up being more complicated than it needs to be.
If you still don't see this the same way, imagine what the logical continuation of what you're saying would be for n >= 2 primitives:
while (--len && (*dst++ = *src++) && *++ptr && (*foo-- = *bar--) && ...) ;
Surely at this point it's obvious that the whole is more complicated than the sum of its parts, right? Maybe one way to see it is that the composition of these n primitives doesn't just have n termination conditions; it has an exponential number (at least 2^n) of them. That's objectively exponentially worse. The only way you (or or your future reader) can dismiss differences in each of those states is by actually spending time analyzing the structure of the problem beforehand; it's by no means a given for an arbitrary problem. And n = 2 isn't exempt from this complexity growth.
Stepping back a bit, and this is a little bit tongue in cheek, but I'm reminded of a joke that seems kind of relevant... it's been years, but if I recall, it went something like this:
A mathematician and an engineer are presented with a problem to solve. Each of them is separately brought into a kitchen, where they see a curtain catching fire. They're asked to put it out. They think for a while, frantically looking around for something useful, and suddenly see there's a pot of water boiling on the stove. They look around, find some mittens, grab the pot, and dump the water on the curtain to put out the fire.
The next day, they're asked to put out a similar fire again, except this time the stove is off. The engineer sees the pot of water, starts looking for a mitten, but then notices the stove is off. So he just picks the pot up with his bare hands and dumps the water on the curtain. The mathematician instead just notices the stove is off, then turns it on to boil the water so he can finish the rest of the problem with the solution he figured out yesterday.
It's exaggerated, but I think a similar idea applies here. Essentially, you don't want to treat everything like a nail just because you have a hammer. Even in a hypothetical world where we could assume the primitives are perfect in isolation and your composition is 100% guaranteed to be bug-free code, this kind of "have idioms -> will use" approach to writing software would merely be guaranteed to give correct code with an upper bound on the required complexity. It would by no means guarantee the final solution is anywhere as simple or readable as it could or should be for that problem, and in this case, IMHO it just isn't.
- saagarjha 5y agoOh, that actually makes more sense! To be clear, when I said your code goes "whoops" I didn't mean it was doing anything wrong, it just seemed like you were correcting for a poor choice in bounds, but now I understand that you were actually aiming to copy one fewer character in all cases. So in this case you were actually maintaining the invariant "I need to write a NUL byte" across both cases, which makes sense because you see this part as being the most important attribute of the code, so much so that you interrupt the string copy to ensure it goes through that specific case at the bottom. So I guess the direction I went with this is that once the loop is over the string is either fully copied, or we've filled the buffer and need to write the NUL, and you've gone with "I've copied everything but the last byte, now I should do that (except for that empty buffer case)". I also want to say that I'm not advocating "forcing" together primitives when they don't work. In this case I had a general idea of which ones might work, then I did a bit of mildly clever rearranging so that the invariants I chose to verify correctness happened to align with the idioms. When you can do this, it's great: you get the benefits for free. But in a lot of cases this isn't going to work, and then the way these idioms fail is that you can't just make something new and confusing out them, you just can't use them anymore. They're like functions from a library: when they work, they're great. But if your needs can't match exactly then you really can't use them at all. So they're really an additional tool in your toolbelt, rather than a general purpose replacement. If you give me slightly different requirements, I might not be able to use the idioms anymore, and my code might look like yours. That's totally fine, but it just means that I need to go through the whole "does this loop work correctly? What are the invariants?" stuff again because I lost the ability to offload this to the idiom. Finally, once you actually write programs this way you'll notice that monstrosities with multiple primitives essentially don't get written. The reason for this is that you can only really use multiple idioms if their invariants happen to line up, and this becomes substantially less likely as you combine them, providing a sort of selective pressure towards readability. In this case they did match and you'll notice that I essentially handle no more termination cases than you do. If the invariants line up correctly the termination conditions will collapse together into fewer cases; when they don't you will know immediately because you'll do stuff like write a long if-else chain at the end to "fix" the odd cases or on one branch add an extra loop to "finish the job" because the invariant didn't actually hold in that case. So, I think I agree that you can make monstrosities if you push the idioms too far, I think they are exceptionally good at "breaking" in that case and you'll quickly realize that you've gone down the wrong path. Just like you can learn to recognize the idioms, you'll "know" when they can apply and you won't force them into places where they shouldn't be used. If done correctly you'll get a concise and "elegant" application without very obvious signs of trying to "undo" what you just did at the end because the idiom didn't match the problem.