r/rust • • 23h ago

🧠 educational Discovering the language: labeled block - implement early return style control flow w/o sparate function

There are situations when decisions must be made based on many variables, and in some points in evalution process, additional costly operations must be performed in order to decide. These decisions are difficult to implement in the form of a single logical expression, and even if it is possible, it is unreadable and hard to modify.

Usually the best pattern for this is to create a separate function which implements the decision, where we can use early returns: the trivial and easy cases that can be decided based on a simple condition (especially exceptions) are evaluated first, we return with the result as soon as possible, so as we're going foward, we reduce the complexity of the remaining cases, and the last case is often a simple condition.

Sometimes a separate function is not an option, because too many parameters would have to be passed (and returned, but Rust have tuples for it), or it simply just does not feel right to split a single decision into two functions, I think, the single-responsibility principle (SRP) must work this way, too. It's even more true for not too complex but nested cases (certain part of the condition is consist of more sub-conditions).

In C and C++, I usually implement this pattern by creating a do..while(false) loop, from which I break at several points, skipping the rest of the evalutation. I store the result in a variable declared just before the block, usually initialized to the default value.

It may just be my fault that I haven't studied the Rust textbook enough, but I've only found the functionally equivalent syntax for this today, and I am very happy with it, with its flexibility and elegance: labeled block.

Let's see an example!

The decision is about whether close (do_close_old flag) the old time window and/or replace with a new one (do_replace_window flag), also log some info about the decision (replace_reason enum), see example (close to actual code):

let (do_close_old, do_replace_window, replace_reason) = 'switch: {

    if just_created {
        break 'switch (false, false, Reason::Create);
    }

    if window.is_retired {
        break 'switch (false, true, Reason::Replace);
    }

    if window.is_expired(timestamp, lifetime_duration) {
        break 'switch (true, true, Reason::Timeout);
    }

    // complicated condition, fake
    let compli = if (x && y) | (a && !b);
    let cation = if z > w {
      countries.find("USA") && languages.find("English")
    } else {
      false
    };
    if compli && cation {
        break 'switch (true, true, Reason::ComplicationHappened);
    }

    (false, false, Reason::None)
};

(Please, don't review my code, I know, I know, somehow the result tuple should be replaced with some named thing.)

I think, it's pretty well readable, even if you haven't met with labeled blocks before. Just as me, until today.

AI disclaimer:

  • helped in translation (my English is not suitable for publications)
  • the pattern was also suggested by AI, I often ask it to refactor short code snippets
5 Upvotes

14 comments sorted by

5

u/Solumin 22h ago edited 22h ago

This is the kind of discussion that can be very difficult to have, not to mention a bit frustrating, because there's just too much missing context. Like, why is do_close tightly coupled to do_replace_window? What sort of logging are we doing that requires passing around an enum like this? How is Reason::None a useful state to have?

Most importantly: why not just do a big if statement? Throw the complicated conditions into helper functions and you get a shorter and cleaner implementation:

rust let (do_close, do_replace_window, replace_reason) = if just_created { (false, false, Reason::Create) } else if window.is_retired { (false, true, Reason::Replace) } else if window.is_expired(timeout, lifetime_duration) { (true, true, Reason::Timeout) } else if compli(a, b, x, y) && cation(z, w, "USA", "English") { (true, true, Reason::ComplicationHappened) } else { (false, false, Reason::None) };

This if statement and your labeled block are fundamentally the same thing: a sequence of comparisons, stores, and jumps. (Same with your do...while(false) construction in C/C++.) I would expect the generated assembly to look identical, modulo compiler support for both patterns.

There are probably other ways to write this. It's really hard to say in an abstract case like this --- I mean, (false, false, Reason::None) just looks wrong to me, because it's repeating the same thing ("Nothing to do") three times. Two of the branches are the same thing, except for the Reason part.\ Man, I don't know. It just looks off to me. I get the feeling this is GUI code and that tends to be weird in ways that other code isn't, so maybe that's what I'm feeling.

2

u/crusoe 14h ago

Encode the return type as a enum not a tuple then matching on the tuple to determine if window is closed or replaced.

The tuple can then have convenience methods like "should_close" or "should_replace" etc. Or you can use other functions to examine it.

1

u/ern0plus4 18h ago

Not GUI window, time window.

do_close and do_replace_window follows right after the decision, they're trivial:

if do_close {
  ...
}
if do_replace_window {
  ...
}

I have only problem with if..elseif..elseif that if I want to insert some calculation, it breaks the simple if-elseif pattern, adds a level of indentation (better say: reveals).

if x {
  ...
} else if y {

} else {

  let compli = ...;
  let cated = ...;

  if compli || cated {
    ...
  } else {
    ...
  }
}

1

u/Solumin 12h ago edited 12h ago

do_close and do_replace_window follows right after the decision, they're trivial:

Wait, then why even make the variables at all?

if just_created { debug!("Not closing or replacing window"); } else if window.is_retired { debug!("Replacing retired window") replace_window(); } else if window.is_expired(timeout, lifetime_duration) { debug!("Closing and replacing expired window") close_old(); replace_window(); } else if compli(a, b, x, y) && cation(z, w, "USA", "English") { debug!("Closing and replacing window for complicated reasons") close_old(); replace_window(); } else { debug!("Window is fine, not closing or replacing") };

if I want to insert some calculation, it breaks the simple if-elseif pattern, adds a level of indentation

Then put that in a helper function?

You've mentioned SRP a couple times. I think you're being too strict about it. "Each function is responsible for a single task" is not mutually exclusive with "functions can call other functions".

9

u/Excession638 23h ago

I'm aware of the syntax, I know what it can do, and I have never used it. I think when I have that much local state, I tend to put it into a struct and create methods on it, even if the external usage pattern is just MyType::new(...).run(). Too much in one function does my head in.

LLMs probably use this because they're applying patterns from other languages, and don't much care about readability.

2

u/ern0plus4 23h ago

The original was a simple if..elseif..elseif..else.

The story is a typical complex-for-first-sight-only situation: there're many exceptions (retired, expired, and some not even mentioned) and a simple decision after the exceptions.

Anyway, I'm struggling for 30 years with "the function is too long, but only because the quantity of simple conditions" cases, and I have no good answer.

2

u/Inner-Asparagus-5703 22h ago edited 22h ago

this exact reason. I think LLMs really struggle to write rust for some reason, so they usually just write c++ or few other langs but in rust syntax.

3

u/Pretty_Jellyfish4921 22h ago

Unrelated, but I also discovered a weird syntax for match arms

match Some(1) {
  val @ Some(_) => val,
  None => None,
}

Before that I used to do

match Some(1) {
  Some(1) => Some(1), // wrap the value again
  None => None,
}

1

u/ern0plus4 18h ago

Wow, never seen such - time to read the book!

2

u/puttak 22h ago

You can also use this on match like:

rust match a { x => 'b: { if abc { break 'b; } } }

1

u/Inner-Asparagus-5703 22h ago

what is the reason to not have same block as function? it barely changes anything & I would argue looks better

1

u/ern0plus4 18h ago

In this case one extra function is okay, but if there was a nested condition, it would require to add another function, which is - in my view - going against SRP.

In C, similar cases are the only legal usage of goto (jumping 1. foward 2. out-of-scope), and I like any languages wich have syntax for it (e.g. break <label>).

1

u/Inner-Asparagus-5703 14h ago

still, it's not very good way to do it, there is a reason why goto is massively avoided. Look at other comments with match arms - looks better, easier to write and read, etc. Oh, and i would say it's really main rusty way to do it.
Good luck on your journey, dude.

2

u/fixedpointfae 11h ago

your cation needs an anion to balance it out