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
1 Answer 1
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 withlevel
inSnoop::log()
; this can be solved by renaming either the function parameter or the member variable namedlevel
. I suggest renaming the latter tomaximum_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 thestd::ofstream
: you can default-construct astd::ofstream
that is not associated with any file, and you canstd::move()
andstd::swap()
std::ofstream
s just like you can withstd::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.