In what order

I came across the Odin language recently. I’ve not done any detailed research yet but I came across a new keyword and it got me thinking.

defer

The defer keyword goes in front of any statement and delays the execution of that statement until the end of the current scope. A possible use case is in file handling code:

file, error := os.open("filename.txt")
if error != os.ERROR_NONE {
    // Handle error.
    ...
}
defer os.close(file)
// Use file.
...

So the file is opened, any potential error is dealt with, the file close operation is deferred, the file is used, the file close operation actually happens. With extensive file processing it’s easy to imagine there would be a big gap between the open and close. By using defer you can put the open and close together. It makes it easier to remember to do and easier to see that it has been done. If the file processing could fail and exit the function early then the close operation will always happen. You don’t have to sprinkle extra calls to close throughout the function.

A similar thing could be done in C++ using destructors and lambdas:

auto file = fopen("filename.txt");
if (file == nullptr) {
    // Handle error.
    ...
}
DeferLambda dummy([&file]() { fclose(file); });
// Use file.
...

This is definitely clumsier requiring an extra class, a dummy variable and a lambda definition. On the other hand with C++ you could create a class to open and close the file for you, indeed that’s in the standard library.

Odin is using a different technique to get a similar effect. I think this is more likely to have problems than using destructors but less likely than traditional file handling code. It’s more general in some ways, you can defer any statement or block, but it’s less general in others, you defer it to the end of the scope a destructor might be tied to something larger. It is more explicit than a destructor you get to craft both the open and close.

Order of execution

Really defer made me start thinking about the order in which code is executed. In a traditional function code is executed from the top to the bottom, potentially skipping some sections and repeating others. However there’s a definite trend from top to bottom. During that you might call out to any other function which could be declared anywhere. So within a function there generally was an order and outwith a function there generally wasn’t.

Function pointers don’t add too much. The function is declared elsewhere like other functions and it’s called like other functions. Destructors do have a delayed action but they are just functions so don’t add anything surprising.

However there can be surprises and when there are surprises bugs are more likely.

Closures

I tend to think of them as lambdas but technically they are called closures. A lambda or anonymous functions cannot use any non-local variables whereas a closure can capture variables during creation.

A closure is declared in one part of a function but then used later in the function.

void PrintSum(const std::vector<float>& values) {
    const auto add = [](float value, float accumulator) {
      return accumulator + value;
    });

    const auto sum = std::accumulate(values.begin(), values.end(), 0, add);
    printf("Sum:%d", sum);
}

This is simple but breaks an expectation of our simple summary of order of execution. Something more complicated could look like this:

auto NotifyServer(const std::string& key, const std::string& data) {
    const auto encode = [&key](const std::string& message) {
        ...
    });
    const auto decode = [&key](const std::string& message) {
        ...
    });
    
    // Establish network connection.
    ...
    connection.Send(encode(handshake));
    const auto handshakeResponse = decode(connection.Receive());
    // Send main data.
    ...
    connection.Send(encode(data));
    const auto dataResponse = decode(connection.Receive());
    // Orderly disconnect.
    ...
    connection.Send(encode(signoff));
    const auto signoffResponse = decode(connection.Receive());
    ...
}

Maybe not the best example but it shows execution can jump back and forth between inside a function. It could get arbitrarily complicated. If the closure is capturing variables or references that are changing is could get very complicated.

Exceptions

I’ve suggested there can be issues with exception handling before. If you’re reading a function then exception handling can mean skipping to the end at any point.

void PrintDocument(const std::string& filename) {
    try {
        // Open document.
        ...
        // Prepare printer settings.
        ...
        // Send document to printer.
        ...
    }
    catch (FileException exception) {
        ...
    }
    catch (DocumentException exception) {
        ...
    }
}

Depending on which exceptions you catch you may or may not be executing other code. There’s no finally keyword in C++ but lots of other languages have them.

Gotos

There isn’t anything more able to do this than the goto-statement.

void ParseSource(const std::string& source) {
start:
    ...
    if (character == '+') {
        goto addToken;
    }
    ...
addToken:
    ...
subtractToken:
    ...
multiplyToken:
    ...
divideToken:
    ...
}

As parsing and lexing code is normally automatically generated you might find this sort of thing despite the general avoidance of goto.

In the end

By definition flow control is responsible for what code does and doesn’t get executed and in what order:

  • Branches jumping over something.
  • Loops repeating something with break and continue adding shortcuts.
  • Defer delays something.
  • Closures jumps to and returns from something.
  • Exceptions jump to another level.
  • Gotos jump wherever they want in a function.

So defer isn’t doing anything revolutionary. It’s one of a number of tools that add useful variety to a straight line execution path. goto gives you maximum freedom but can easily create code that is hard to understand. The others are more limited but easier to understand because of those limitations.

You can write confusing code with any of them but avoid it by:

  • Keeping functions to a sensible size.
  • Don’t have too many levels of nesting. The more you have going on the less clear where you’re going from and to.
  • Give flow control space. I don’t put full loops or if-statements on a single line. break, continue and return also get a line to themselves.

I haven’t used defer myself but I’d guess:

  • Don’t defer too many things at once. The more things there are the easier it is to miss one.
  • Be clear about the intended scope. If you have a lot of scopes with different deferred code in them it many not be clear what will happen when.

Posted

in

by

Tags:

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *