The benefits of being lazy

In programming, laziness can often be an asset.

It has been said that it is a good trait for programmers themselves. It has to be a forward-thinking laziness, not a short term avoidance of work. Being methodical in the reduction of how much work you have to do via automation or streamlining of processes over time generally is a huge win for both you as an individual and the organization you’re applying your effort in service of. Today, however, we’re going to be looking at an entirely different sort of laziness. Computer programs can themselves be lazy, and sometimes that’s a good thing.

Lets imagine we’re writing a program, and we want some logging to help us out while we’re developing it, so we add a method like this:

public enum Category{INFO, WARNING, ERROR}
public static void Log(Category category, string message)
{
    Console.WriteLine($"{Enum.GetName(typeof(Category), category)}: {message}");
}

Now we can call it like this Log(Category.INFO, "This is a message"); and we get a log message like this:

INFO: This is a message

This is simple and effective, but logging can often be surprisingly expensive, especially if we do it in loops. Say we notice all that helpful logging we added really slows down our application. We find our logging to be really useful for development, but once we release our application, we don’t need it as badly. We could turn it off entirely with a  [Conditional("DEBUG")] attribute on the function, but then we might miss out on valuable logging when warnings or errors occur in released applications. So lets add some log level controls:

public enum Category{INFO, WARNING, ERROR}

public static Category LogLevel = Category.INFO;
public static void Log(Category category, string message)
{
    if (category >= LogLevel)
    {
        Console.WriteLine($"{Enum.GetName(typeof(Category), category)}: {message}");
    }
}

Now all we have to do is make sure we set  LogLevel = Category.WARNING; in released applications and we don’t emit the Info level logs anymore! If we were profiling things (which you should be doing from time to time) we would notice our program runs faster when change the log level to exclude the majority of our low priority logging. If we dig deeper into that profiling though, you may notice that calls to Log still make up a surprising amount of our time executing.

To understand why this is, lets look into what about logging is expensive. Here’s a simple little test case:

static void Test()
{
    var list = Enumerable.Range(0,1000);
    var secondList = new List<int>();

    foreach (var element in list)
    {
        secondList.Add(element);
        Log(Category.INFO, $"secondList is now [{string.Join(",", secondList.Select(e => e.ToString()))}]");
    }
}

If we profile this method, we get something that looks like this:

1845 ms, or 77% of our program’s execution time is spent just in the WriteLine method! This means we can save a lot of time by changing our log level. Here’s the same code with the log level set to Warning:

That saved us almost 2000 ms! Go us! 355 ms still seems really expensive for what is essentially just coping a 1000 element array. So how fast can we get? What if we took the logging out entirely?

1.7 ms! That means our program was spending 99% of it’s time getting ready to log, and then not actually logging. If we look at the profiling for the previous run, we can see we’re spending almost all our time in string.Join , string manipulation is expensive!

Now, at this point we might want to seriously question whether that logging is worthwhile, but lets assume for the moment that it is useful enough that we’d like to keep it. So, how do we avoid creating those strings when we aren’t going to do anything with them?

Well, we could wrap every invocation to  Log like this:

if (Category.INFO >= LogLevel)
{
    Log(Category.INFO, $"secondList is now [{string.Join(",", secondList.Select(e => e.ToString()))}]");
}

But if that doesn’t make you die inside a little, well, we’ve had some good times, but I think it’s time we started to see other projects…

So, how else might we do this? Well, by getting lazy of course:

public static void Log(Category category, Func<string> message)
{
    if (category >= LogLevel)
    {
        Console.WriteLine($"{Enum.GetName(typeof(Category), category)}: {message()}");
    }
}

//And we call it thusly:
Log(Category.INFO, ()=>$"secondList is now [{string.Join(",", secondList.Select(e => e.ToString()))}]");

If it takes you a moment to spot the changes, don’t worry, they’re subtle. In the Log method we changed the type of  message from  string to  Func<string> and since it’s now a function, we have to call it ( message()) to get the actual string to print. In our call to  Log , we just added  ()=> in front of the string we’re passing in to make it an anonymous function that takes no arguments and returns our message string.

If you haven’t done a bunch of work with passing functions to functions, this may take a moment to puzzle out how that works, but it’s benefits are pretty powerful:

3 ms. Still a fair bit slower than our ideal case (just calling the log function costs something, and there is a closure allocation in there) but it is still a great deal better than the 355 ms we were paying before we made it lazy.

Logging is a place where laziness is fairly obvious in it’s application and benefit, but often it’s benefits are much more subtle.

The future of work

One way to think about what we’ve done here is that we’ve deferred work until later, and we’re creating a representation of that work with our Func<string> message variable. If we avoid calling that function, we avoid doing the work, and what is laziness but the avoidance of work?

This is a powerful concept though. If a variable is an abstract representation of data, then a function variable is an abstract representation of work.

Callbacks are probably the most common way to think about deferred work, but all of the Linq library in C# is built around the concept of separating the important decision making from actual iteration.

The ability to express work that will occur later also opens up the door to to certain styles of asynchronous programming. If we can save a representation of work to be done later, we can do it across time, or across threads (with all the caveats about ensuring memory isolation).

Infinite laziness

Computers are not very good at infinity. To be fair, most things are not very good at infinity. Infinity might only exist in pure mathematics, so perhaps we can forgive computers their shortcomings for being physical things in a finite universe, but infinity, or at least reasonably unbounded things can be a useful concept in programming.

Laziness is often a subtle thing, and you likely don’t want to use it for every problem, but it is a marvelously useful tool in the right circumstances, and if you’re familiar with the technique, you might find surprising places to use it.

 

0 thoughts on “The benefits of being lazy

Leave a Reply

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

This site uses Akismet to reduce spam. Learn how your comment data is processed.