Hacker Newsnew | past | comments | ask | show | jobs | submitlogin

I can’t answer why other people might do this, but I wrote this code so I can at least give my rationale for it. In short: it leans into C idioms to convey intent. “--len” in a loop condition is a way to say “do this len times”. “*dst++ = *src++” is quite literally the reference strcpy implementation. Taken together, it clearly and quite concisely represents the main operation of this function: do a strcpy, but only up to len characters. There is some extra bookkeeping that needs to be done for errors and null-termination, and that’s delineated from the core part of the code. In all, I read the function as “bail out early if there is nothing to do, otherwise do the copy operation and then fix up the result”.

Now, of course you might say that my code is prone to off-by-one errors and the like. And it totally is! This is C and you are going to get string handling wrong, guaranteed. When I was writing this I personally ran it through the typical edge cases and it caught an instance where I (IIRC) was writing an extra NUL when I had exactly filled the buffer previously. These things happen and you need to verify the code yourself to fix it beforehand. But, back to the point, I don’t trust your code any more than I would mine. I mean, you’re also breaking out of a loop on an additional condition and reusing the index variable for later logic. That’s basically a big red flag for “there might be bugs here”. There’s really no way to avoid it. I would argue that the most complex part of this code is actually verifying that the transition between the loop and the error handling that happens afterwards is correct, not the copying bit. Adding a few extra variables splits up the logic a bit but it also gives you more loop invariants to keep track of. Anyways, that’s my 2¢.



I'm not talking about correctness though. I'm talking about readability. There are so many (classical!) principles of readability (and debuggability) that are all being violated in that one function:

- Not mixing pre and post increments

- Not altering function arguments

- Minimizing (not maximizing!!) side effects in conditionals

- Putting braces around a loop body

- Having fewer entry and exit points (lots of returns especially make debugging hell)

- Just minimizing needless mutation in general

etc.

To give an example, I have such a hard time figuring out what even 'len' is in your code after the loop, and what it implies. I have to think about whether it's signed or unsigned, whether it might wrap around or not (I actually thought you had a bug until I saw the earlier return), whether it ends as zero or something else, and what its relationship to the pointers is. (e.g., whether they all change by exactly the same amount.) Whereas just using a simple index without modifying the arguments just erases all those problems altogether.

It's one thing to feel they're both equally likely to be buggy (I actually don't particularly agree on that either, but I see what you're saying and think your viewpoint is fine there), but do you find them equally readable/understandable/debuggable too? Surely you can agree it's easier to read code that modifies just 1 local variable and has 1 return with just 1 extraneous increment than one that modifies 3 arguments simultaneously, misuses the natural point of a loop, and then adds several returns to handle cases with only minor variations?

Btw, I'm not even particularly concerned about your ability to write code like this. I'm worried about propagating dangerous practices to other people. IMO, for people (or teams) who can write exceptionally correct code even in an unconventional manner, I just keep my mouth shut and let them do their thing as best as they can... internally. But seeing practices that are known to be error-prone for decades still being exhibited and propagated to us mere mortals who are likely to make predictable (human) errors is what bugs me. Especially when the very goal is to teach people to write safe and secure code!


I just want you to know that I find your comments to be interesting, they're really forcing me to find words for why I wrote the function this way! Oh, and ignore 'userbinator; I'm pretty sure I understand their point but they know better than to be expressing their views like that.

Anyways, to address your points: I suspect we are reading this function at different "levels". You see it as a mess of mutation and increment operators, but it is written that way on purpose! The reason for this are the "idioms" I mentioned above. You might be aware of some in C already, like

  struct foo f = { 0 };
To someone who is unfamiliar with the syntax this is can be confusing: "it's assigning 0 to the struct…what?!" Logically, this zero-initializes the first member of the struct, then implicitly zero-initializes all the other members. But an experienced reader isn't going to read it like that, they're going to read it like "clear out the struct"–they could probably tell you how the C standard makes this work and why this is better than memset, but in their brain when they read it they just know the idiom " = { 0 }" and what it does without going through all that. Similarly, in functional languages you might see code like

   let product = [1, 2, 3, 4].fold(*)
which computes the product of all the numbers. Again, someone can explain how the whole thing works, and * is a binary operator that is being coerced into a closure argument, or whatever, but at some point you just see ".fold(*)" and think "product". At some point you may even prefer seeing ".fold(*)" over

  var product = 0
  for i in [1, 2, 3, 4] {
      product *= i
  }
because it's concise and doesn't leave room for error (did you spot the bug? I've done this more than once. It's easy to find and fix, but still, hopefully it illustrates how these kinds of things happen.) Now, with that in mind, we have some idioms in my code. These are what they are:

  while ((*dst++ = *src++));
I "know" this idiom as a strcpy. I also "know" that the loop invariant on this is that dst and src always point to the next character to copy: this is true at the start of the loop, while it's running, and at the end except at that point you're done copying so you don't want to run this loop anymore. I don't futz around with the increment operators, I just have internalized this to be true and read it as such. Similarly,

  while (--len);
is just an idiom to run a loop len - 1 times (I made a typo in the first reply, heh). At the end len is zero because it's also the condition. With that done, I think the algorithm I implemented becomes clear:

1. If the buffer has no space, return early. (You can see this is a special case bailout–you'll see why it exists in just a second.)

2. Do a strcpy. But, before we copy any character, we need to also check to make sure you haven't run more than len - 1 times (Why? Again, see below). This check actually has to happen "first" so put it in front of the &&, to make sure we bail before writing a character.

3. Ok, so the loop is over. Since we have two conditions, this means there are two possible cases: either we hit the limit, or we finished copying the string. Actually there are three, since both could happen at once. If len is zero we ran out the buffer, so we need to null-terminate. Because we got rid of that pesky empty buffer case at the beginning, and we've copied len - 1 bytes, we have a guaranteed invariant here: we need to write one last NUL byte, and we have the space for it. Because we used the strcpy idiom, we know that dst has been pointing at the next spot to write to all this time, so we use it now. And src is pointing at the next character we want to write, so it lets us conveniently fill that case where the string fits perfectly, because we can figure this out from the input. The other side of the if is simpler, we simply finished copying without problems, so we can return dst as-is.

Ok, that was a lot of words, but when I am reading it these stuff just comes automatically. This is why I brought up correctness: readability and correctness go hand-in-hand here. The readability principles you bring up are generally good ideas, but I chose to eschew them here precisely because the idioms give much powerful invariants that I think they are just more readable. Your function breaks out of the middle of a loop to ensure it doesn't overrun the string, mine just naturally rolls it into the loop condition. Your program increments dst past the end, goes "whoops", then goes back and writes in the NUL byte; mine only moves forwards and ensures that dst points to the place I want to write next from the moment the function starts all the way to the points of return. There is one edge case where I don't write a NUL (which 'saurik also noticed because he also tried to implement this function), but I bail out of that one immediately so the rest of the function can assume that it can write to the end of the buffer.

So, in summary: I know how the increment idioms work. I don't see loops or mutation or side effects, I see the code as "this is the high-level thing that this sequence of characters will do", and then I structure my function around it so that the guarantees I know it can provide apply to my program.


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.


Oh, 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.


There are so many (classical!) principles of readability (and debuggability) that are all being violated in that one function

In other words, you're just repeating cargo-cult "I don't like how this looks" dogmatism and rejecting it outright, instead of making any attempt at actual understanding.

I strongly recommend that everyone should try an APL-family language, even if only a little bit, to realise that there's a whole world of programming beyond the lowest-common-denominator tripe that's common these days. The mind needs to be exercised.


If len is 1 at the start, won’t this loop not execute one time?

I thought predecrements are executed immediately?


Oops, yes, I forgot to add the "-1" to the end of that. The blog post has the right invariant:

> this function will copy the smaller of strlen(src) or len - 1 bytes from src to dst




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: