DEMKO.CA

Explicit vs Lazy Global Variables

By Aleksander Demko, October, 2023.

The issues with globals variables

Global variables (sometimes known as singletons) are generally viewed as something you want to minimize in an application.

They make things:

However, having said all that, sometimes it does make sense having a few global variables in your application. This applies to things that are so general and common to all your functions that it doesn't make sense to require passing them to every function in your application. For example, logging libraries often fall into this category.

Lazy globals

So you've decided to have a few globals and like many many developers you've made them 'lazyily' (or implicitly) constructed. By that I mean, the global variable is initialized by the variable declaration itself.

For example, a global logger might be defined as:

Note: Although I'm using TypeScript code below, this probably applies to ALL programming languages

export let loggerInstance = new ConsoleLogger(Level.Info);

and used as:

import { Level, loggerInstance } from './logger';
loggerInstance.write(Level.Info, 'hello, lazy logger instance');

This is short, sweet and gets the job done. Lots of articles on the web say to do it this way. However, it's a trap and instant technical debt and you should fix it right away. Why? Read on...

Lazy global issues

There are a few issues with lazy globals that you'll discover in the middle or late range of your project. If you lack the experience to understand the core issue, you might flail around patching the system to work around the various issues of lazily constructed global variables. Maybe I can save you some time.

Some issues related to start up control:

  1. You can't control when the global variable is created, since it's created upon first use or on module load. You're not even sure who is triggering the first use, since it's so casual. This is a huge problem if the singleton has to access external resources (such as files or the network) and those aren't configured yet. You might also - in certain configurations - not want the singleton to be active at all. Perhaps all uses of the singleton is an error and should be immediately return an error to the calling code.

  2. You can't control HOW the singleton is created. In my logger example, how can I change where the logs are output or at which logging level the messages are filtered out? Perhaps I need to parse some command line options or a configuration file. Ahh, you think, perhaps I'll make those lazily constructed too, which leads me to...

  3. Lazy loading spreads like a virus in your code. Any code that a lazily loaded global needs also has to be lazily loaded. For example, if our logger needs to read a configuration file before deciding how to configure itself, that configuration file needs to be lazily loaded.

Other issues with lazy loading are related related to modularity & maintenance:

  1. You can't easily test your globals since you can't instantiate the global in tests nor tell your functions to use a difference instance.

    You could work around this with mocking libraries, but that means you have to write a lot of hacky mocking code.

  2. You don't know how to cleanup & shutdown your globals. Since you can't really tell who still needs them or their start up order, you don't really known when (and in what order) you can shut them down.

    This might not be a problem for a simple console logger, but for anything sightly more complex, such as things with open resources such as files, network connections, database connections, etc, you should really close them properly.

  3. If any of the singletons represent threads or background processes, then I'd say you must destruct them properly, otherwise you can't really be certain what state your program is in during shutdown.

  4. It quickly hides the various dependencies on your globals, making it hard to understand all the dependencies in your program and obscuring some architectural details.

Solution - explicit construction and destruction of globals

At a high level, the solution here is to be diligent and require explicit construction (and destruction) of globals in your program's main-like function. No lazy loading. This has a few secondary tenets too:

  1. All globals instances must have construction and destruction functions. It's ok to use one function for both (for example, setInstance(not-null) could be used for constructon while setInstance(null) might destruct the global instance).

  2. Only the main function (or its close helper functions) should call that function. Only the main function should be concerned with the details of the singleton implementation.

  3. You can take point 2 further by separating the general interface of your global from your implementation. For example, 99% of your code is only concerned with the write function of a ILogger interface.

    Only the main function is concerned with the implementation details, such as wether it's a ConsoleLogger, FileLogger etc backing that interface. Separate your files accordingly.

  4. For ease of testability, don't hard code the 'global instance' into the code itself. What I mean by this is that I should be able to instantiate and use a FileLogger without changing the global instance. Do NOT make all their methods static that always access the global instance.

    For example, prefer calling globalInstance.write() over Logger.write().

  5. You might want to gate all your global instance access through a function, since this allows you to put additional checks or redirections if needed.

    For example, LoggerInstance().write()

Putting it all together - let's see some code

Putting all the tenets together, our explicitly loaded logging system might be implemented as follows.

First, in logger.ts we'd present the global instance itself and the high level Logger interface. This is the code that 99% of the application will need:

// This defines the various logging levels each logging message can use
export enum Level {
    Debug = 10,
    Trace = 20,
    Verbose = 30,
    Info = 40,
    Warning = 50,
    Error = 60,
    Fatal = 70,
}

// This is the global instance. Although you can export the variable directly to other modules, 
// that's not advised since you can't guard it's access with checks and logs. We will guard
// this instance with the instance() function below.
let globalLogger: Logger | null = null;

// The interface that most of the code needs.
export interface Logger {
    // Write a line to the log.
    write(level: Level, msg: string): void;
    // Close the logger.
    close(): void;
}

// Most code will call this function to get the global instance. Since most
// code assumes non-null, this function conveniently checks and throws an exception
// on null, relieving the calling code of doing this check and quickly surfacing
// incorrect access.
export function instance(): Logger {
    if (!globalLogger) {
        throw new Error("No logger has been setup.")
    }
    return globalLogger;
}

// Some code might want to do different things depending on weather the singleton is
// ready. This function provides that service.
export function hasInstance(): boolean {
    return globalLogger != null;
}

// The main function can use this to install and cleanup global instance.
export function setInstance(logger: Logger | null) {
    if (globalLogger != null) {
        globalLogger.close();
        globalLogger = null;
    }

    globalLogger = logger;
}

To use the global instance, most code will call instance().write(...).

The main function however, is where the setup action is:

// Perhaps read any config files or command line parameters that might affect
// the logger's setup and then setup the logger...
setInstance(new ListLogger([
    new ConsoleLogger(Level.Warning),
    new FileLogger(Level.Debug, '/tmp/demo.log')
]));

// (let the program run here)

// When done, finally explicitly clean up the instance:
setInstance(null);

That's it!

Appendix: Additional Code

Although not critical to the topic of globals, here's the implementation details of the various logging functions I threw together. They demonstrate a few things:

import { Level, Logger } from "./logger";
import { openSync, closeSync, writeSync } from 'fs';

class MinLevel {
    protected minLevel: Level;

    constructor (minLevel: Level) {
        this.minLevel = minLevel;
    }
}

export class ConsoleLogger extends MinLevel implements Logger {
    constructor (minLevel: Level) {
        super(minLevel);
    }

    public write(level: Level, msg: string): void {
        if (level >= this.minLevel) {
            console.log(`${new Date().toISOString()} ${Level[level].toUpperCase()} ${msg}`);
        }
    }

    public close(): void {
        // nothing needed
    }
}

export class FileLogger extends MinLevel implements Logger {
    private fileHandle: number | null;
    constructor (minLevel: Level, outfile: string) {
        super(minLevel);
        this.fileHandle = openSync(outfile, "a+");
    }

    public write(level: Level, msg: string): void {
        if (level >= this.minLevel && this.fileHandle != null) {
            writeSync(this.fileHandle, `${new Date().toISOString()} ${Level[level].toUpperCase()} ${msg}\n`);
        }
    }

    public close(): void {
        if (this.fileHandle != null) {
            closeSync(this.fileHandle);
        }
        this.fileHandle = null;
    }
}

export class ListLogger implements Logger {
    private loggers: Logger[];

    constructor (loggers: Logger[]) {
        this.loggers = loggers;
    }
    public write(level: Level, msg: string): void {
        for (const logger of this.loggers) {
            logger.write(level, msg);
        }
    }
    public close(): void {
        for (const logger of this.loggers) {
            logger.close();
        }
    }
}