3
\$\begingroup\$

I started learning C++ recently for game development, and decided to write my own little library for debugging.

Here is the library (snoop.hpp)

#ifndef SNOOP_HPP
#define SNOOP_HPP

#include <chrono>
#include <fstream>
#include <iostream>
#include <memory>
#include <string>
#include <mutex>
#include <sstream>
#include <functional>

// Enumeration for different log levels
enum class LogLevel {
    FATAL,
    ERROR,
    WARN,
    INFO,
    DEBUG,
    VERB
};

// Forward declaration of Snoop class
class Snoop;

// Type definition for custom error handler function
using ErrorHandler = void (*)(const std::string&);

// Type definition for custom log message formatter function
using LogFormatter = std::function<std::string(LogLevel, const char*, int, const std::string&)>;

class Snoop {
public:
    // Constructor
    Snoop(LogLevel level = LogLevel::INFO, std::string filePath = "", bool log_to_console = true);

    // Destructor
    ~Snoop();

    // Copy Constructor (deleted)
    Snoop(const Snoop&) = delete;

    // Copy Assignment Operator (deleted)
    Snoop& operator=(const Snoop&) = delete;

    // Move Constructor
    Snoop(Snoop&& other);

    // Move Assignment Operator
    Snoop& operator=(Snoop&& other);

    // Method to set a custom error handler
    void SetErrorHandler(ErrorHandler error_handler);

    // Method to set a custom log formatter
    void SetLogFormatter(LogFormatter formatter);

    // Public log methods for different log levels
    void Fatal(const char* file, int line, const std::string& message);
    void Error(const char* file, int line, const std::string& message);
    void Warn(const char* file, int line, const std::string& message);
    void Info(const char* file, int line, const std::string& message);
    void Debug(const char* file, int line, const std::string& message);
    void Verb(const char* file, int line, const std::string& message);

    // Methods to configure logging behavior
    void LogToFile(std::string filePath);
    void LogToConsole(bool log_to_console);
    void SetLevel(LogLevel level);

private:
    LogLevel level = LogLevel::INFO;
    bool log_to_file = false;
    bool log_to_console = false;
    std::mutex mutex;
    std::unique_ptr<std::ofstream> file;
    ErrorHandler error_handler = nullptr;
    LogFormatter log_formatter = nullptr;

    // Helper function for resource transfer
    void swap(Snoop& other) noexcept;

    // Helper function for default message formatting
    std::string formatMessage(LogLevel level, const char* file, int line, const std::string& message);

    // Internal log function used by public log functions
    void log(LogLevel level, const char* file, int line, const std::string& message);
};

// Macros for simplified logging usage
#define LOG_FATAL(instance, msg) instance.Fatal(__FILE__, __LINE__, msg)
#define LOG_ERROR(instance, msg) instance.Error(__FILE__, __LINE__, msg)
#define LOG_WARN(instance, msg)  instance.Warn(__FILE__, __LINE__, msg)
#define LOG_INFO(instance, msg)  instance.Info(__FILE__, __LINE__, msg)
#define LOG_DEBUG(instance, msg) instance.Debug(__FILE__, __LINE__, msg)
#define LOG_VERB(instance, msg)  instance.Verb(__FILE__, __LINE__, msg)

// Constructor
Snoop::Snoop(LogLevel level, std::string filePath, bool log_to_console) {
    try {
        LogToFile(filePath);
        LogToConsole(log_to_console);
        SetLevel(level);
    }
    catch (const std::exception& e) {
        // Handle initialization exception
        if (this->error_handler) {
            this->error_handler("Exception during Snoop initialization: " + std::string(e.what()));
        }
        else {
            std::cerr << "Exception during Snoop initialization: " << e.what() << std::endl;
        }
    }
}

// Destructor
Snoop::~Snoop() {
    // unique_ptr will automatically close the file when it goes out of scope
}

// Move Constructor
Snoop::Snoop(Snoop&& other) {
    // Transfer ownership of resources
    swap(other);
}

// Move Assignment Operator
Snoop& Snoop::operator=(Snoop&& other) {
    // Self-assignment check
    if (this != &other) {
        // Release current resources
        if (log_to_file) {
            file.reset(); // Close file by resetting unique_ptr
        }

        // Transfer ownership of resources from 'other' to 'this'
        swap(other);
    }

    return *this;
}

// Helper function to swap resources
void Snoop::swap(Snoop& other) noexcept {
    using std::swap;

    // Ensure thread safety by locking both mutexes
    std::lock_guard<std::mutex> lock(mutex);
    std::lock_guard<std::mutex> otherLock(other.mutex);

    // Transfer resources between 'this' and 'other'
    swap(level, other.level);
    swap(log_to_file, other.log_to_file);
    swap(log_to_console, other.log_to_console);
    swap(file, other.file);
}

// Method to set a custom error handler
void Snoop::SetErrorHandler(ErrorHandler error_handler) {
    this->error_handler = error_handler;
}

// Method to set a custom log formatter
void Snoop::SetLogFormatter(LogFormatter formatter) {
    this->log_formatter = formatter;
}

// Method to set the log level
void Snoop::SetLevel(LogLevel level) {
    this->level = level;
}

// Method to configure logging to console
void Snoop::LogToConsole(bool log_to_console) {
    this->log_to_console = log_to_console;
}

// Method to configure logging to file
void Snoop::LogToFile(std::string filePath) {
    this->file.reset(); // Release previous file handle

    if (!filePath.empty()) {
        try {
            // Attempt to open the specified file for logging
            this->file = std::make_unique<std::ofstream>(filePath, std::ios_base::app);
            if (!(*this->file)) {
                // Error opening file
                if (this->error_handler) {
                    this->error_handler("Failed to open file for logging: " + filePath);
                }
                else {
                    std::cerr << "Error while opening log file: " << filePath << std::endl;
                }
                this->log_to_file = false;
            }
            else {
                // Successfully opened file
                this->log_to_file = true;
            }
        }
        catch (const std::exception& e) {
            // Handle exception while opening the log file
            if (this->error_handler) {
                this->error_handler("Exception while opening log file: " + std::string(e.what()));
            }
            else {
                std::cerr << "Exception while opening log file: " << e.what() << std::endl;
            }
            this->log_to_file = false;
        }
    }
    else {
        // Logging to file is disabled
        this->log_to_file = false;
    }
}

// Default message formatting function
std::string Snoop::formatMessage(LogLevel level, const char* file, int line, const std::string& message) {
    std::ostringstream outstream;

    // Generate timestamp
    using clock = std::chrono::system_clock;
    auto now = clock::now();
    auto seconds = std::chrono::time_point_cast<std::chrono::seconds>(now);
    auto fraction = now - seconds;
    std::time_t clock_now = clock::to_time_t(now);
    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(fraction);

    char time_str[20];
    std::strftime(time_str, sizeof(time_str), "%H:%M:%S", std::localtime(&clock_now));

    outstream << time_str;

    // Add log level
    switch(level) {
        case LogLevel::FATAL: outstream << " [FTL] "; break;
        case LogLevel::ERROR: outstream << " [ERR] "; break;
        case LogLevel::WARN:  outstream << " [WRN] "; break;
        case LogLevel::INFO:  outstream << " [INF] "; break;
        case LogLevel::DEBUG: outstream << " [DBG] "; break;
        case LogLevel::VERB:  outstream << " [VRB] "; break;
    }

    outstream << "[" << file << ":" << line << "] ";

    outstream << message;

    return outstream.str();
}

// Internal log function
void Snoop::log(LogLevel level, const char* file, int line, const std::string& message) {
    // Skip logging if the level is above the set logging level
    if (level > this->level) {
        return;
    }

    std::string outstring;

    // Format the log message using a custom formatter if provided
    if (this->log_formatter) {
        outstring = this->log_formatter(level, file, line, message);
    }
    else {
        // Use the default message formatting function
        outstring = formatMessage(level, file, line, message);
    }

    // Log to console
    if (this->log_to_console) {
        // Lock the mutex for thread safety while logging to console
        this->mutex.lock();
        if (level == LogLevel::FATAL || level == LogLevel::ERROR) {
            std::clog << outstring << std::endl;
        }
        else {
            std::clog << outstring << "\n";
        }
        this->mutex.unlock();
    }

    // Log to file
    if (this->log_to_file) {
        // Lock the mutex for thread safety while logging to file
        this->mutex.lock();
        if (level == LogLevel::FATAL || level == LogLevel::ERROR) {
            (*this->file) << outstring << std::endl;
        }
        else {
            (*this->file) << outstring << "\n";
        }
        this->mutex.unlock();
    }
}

// Public log functions that call the internal log function
void Snoop::Fatal(const char* file, int line, const std::string& message) {
    log(LogLevel::FATAL, file, line, message);
}

void Snoop::Error(const char* file, int line, const std::string& message) {
    log(LogLevel::ERROR, file, line, message);
}

void Snoop::Warn(const char* file, int line, const std::string& message) {
    log(LogLevel::WARN, file, line, message);
}

void Snoop::Info(const char* file, int line, const std::string& message) {
    log(LogLevel::INFO, file, line, message);
}

void Snoop::Debug(const char* file, int line, const std::string& message) {
    log(LogLevel::DEBUG, file, line, message);
}

void Snoop::Verb(const char* file, int line, const std::string& message) {
    log(LogLevel::VERB, file, line, message);
}

#endif

And here I have a file that showcases how to use it (example.cpp):

#include "snoop.hpp"

int main() {
    // Create a Snoop logger instance with INFO level
    Snoop logger(LogLevel::INFO);

    // Set a custom error handler
    logger.SetErrorHandler([](const std::string& error) {
        std::cerr << "Custom Error Handler: " << error << std::endl;
    });

    // Log messages at different levels with the default formatter
    LOG_FATAL(logger, "This is a fatal message.");
    LOG_ERROR(logger, "This is an error message.");
    LOG_WARN(logger, "This is a warning message.");
    LOG_INFO(logger, "This is an info message.");
    LOG_DEBUG(logger, "This is a debug message.");  // Gets skipped
    LOG_VERB(logger, "This is a verbose message."); // Gets skipped

    // Set a custom log formatter
    logger.SetLogFormatter([](LogLevel level, const char* file, int line, const std::string& message) -> std::string {
        std::ostringstream formatted;
        formatted << "<" << file << "::" << line << "> ";
        switch (level) {
            case LogLevel::FATAL: formatted << "[FATAL] "; break;
            case LogLevel::ERROR: formatted << "[ERROR] "; break;
            case LogLevel::WARN:  formatted << "[WARN] "; break;
            case LogLevel::INFO:  formatted << "[INFO] "; break;
            case LogLevel::DEBUG: formatted << "[DEBUG] "; break;
            case LogLevel::VERB:  formatted << "[VERB] "; break;
        }
        formatted << message;
        return formatted.str();
    });

    // Change LogLevel to verbose
    logger.SetLevel(LogLevel::VERB);

    // Configure logger to log to a file
    logger.LogToFile("./log/file.log"); // Throws error (directory doesn't exist)

    // Log messages at different levels with the custom formatter
    LOG_FATAL(logger, "This is a fatal message.");
    LOG_ERROR(logger, "This is an error message.");
    LOG_WARN(logger, "This is a warning message.");
    LOG_INFO(logger, "This is an info message.");
    LOG_DEBUG(logger, "This is a debug message.");
    LOG_VERB(logger, "This is a verbose message.");

    // Configure logger to log to a file ONLY 
    logger.LogToFile("./example.log");
    logger.LogToConsole(false);

    // Log additional messages after configuring file logging
    LOG_FATAL(logger, "This is a fatal message (logged to file).");
    LOG_ERROR(logger, "This is an error message (logged to file).");
    LOG_WARN(logger, "This is a warning message (logged to file).");
    LOG_INFO(logger, "This is an info message (logged to file).");
    LOG_DEBUG(logger, "This is a debug message (logged to file).");
    LOG_VERB(logger, "This is a verbose message (logged to file).");

    return 0;
}

Anything I could change or add to make my code more safe and/or performant? General C++ advice is welcome too! Thanks :)

Edit: I applied the excellent advice given by G. Sliepen; you can see the updated version here: https://github.com/makeyen/snoop

\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

Unnecessary things

  • You don't need to forward declare class Snoop, nothing uses it before you actually define the class.
  • this-> is almost never necessary in C++. The only issue is with level in Snoop::log(); this can be solved by renaming either the function parameter or the member variable named level. I suggest renaming the latter to maximum_level.
  • The destructor is empty, you can omit its definition entirely.
  • Catching errors in the constructor: error_handler is never set in the constructor, so it's useless to check for it. It's also better to just let the exception propagate than to print an error message and then to ignore the error.
  • There is no need to delete the copy constructor and assignment operator explicitly; since the member variable mutex is non-copyable, the whole class will be non-copyable by default as well.
  • Using a std::unique_ptr to hold the std::ofstream: you can default-construct a std::ofstream that is not associated with any file, and you can std::move() and std::swap() std::ofstreams just like you can with std::mutex.

Simplify the code

There is quite some complexity in log() because it can choose between custom formatters or a default one, and can log to a file and/or the console. I would also move the early checks for the log level into the class definition so it can be inlined. And together with the use of std::lock_guard and some helper functions, it can be rewritten like so:

class Snoop {
public:
    …
    void log(LogLevel level, …) {
        if (level <= maximum_level)
            log_internal(level, …);
    }

    void Fatal(…) { log(LogLevel::FATAL, …); }
    …
private:
    …
    std::ofstream file;
    void log_internal(LogLevel level, const char* file, int line, const std::string& message);
};

static void log_formatted(bool enabled, std::ofstream& file, LogLevel level, const std::string& formatted_message) {
    if (enabled) {
        file << formatted_message << '\n';
        if (LogLevel::FATAL || level == LogLevel::ERROR) {
            file.flush();
        }
    }
}

void Snoop::log_internal(LogLevel level, const char* file, int line, const std::string& message) {
    std::string outstring = formatMessage(level, file, line, message);
    std::lock_guard lock(mutex);
    log_formatted(log_to_console, clog, level, outstring);
    log_formatted(log_to_file, file, level, outstring);
}

std::string Snoop::formatMessage(LogLevel level, const char* file, int line, const std::string& message) {
    if (log_formatter) {
        return log_formatter(level, file, line, message);
    }
    …
}

The macros can be avoided in C++20

C++20 added std::source_location, which gives you the filename and line number in a way that avoids having to use macros.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.