I want to develop a rich error reporting mechanism for zig. I understands that this involves some overhead.
My personal c code uses this struct, which isn’t very rich, but its quite helpful.
typedef struct {
int code; // non-zero means an error happened
const char *msg;
const char *func;
} Ret;
#define RET ((Ret){0, SUCCESS_STR, __func__})
#define FN_RET(code, msg) ((Ret){code, msg, __func__})
#define FN_ERR() ((Ret){errno, strerror(errno), __func__})
This is generally fine, and i think the overhead pays for itself in the time it has saved me. One problem with this approach is that it is limited on what information can be shown in the msg field.
Sometimes when an error happens in a nested function, only one Ret struct can be propagated up, this means that I have to choose which level of detail to retain. This sucks because many times the error happens because of combined function contexts. I can live with this since I only use it in my personal code and I tend to know my code base fairly well.
However, at work we use cpp, and many times errors have to be passed between different contexts (file streams, Graphical interfaces, stdout, threads, etc..). For this reason i decided to do something like this.
class Ret{
public:
int code;
std::string message;
};
I understand the cost of passing around a dynamic string, but again this is not a logging mechanism, it is an error reporting one. Only error information is processed through this object. The context is appended to the string, we like that we can include formatted strings in the message and be explicit about the error.
I attempted something to that effect in zig, but i think i failed miserably.
This is the function that builds the type
fn ReturnValue(T: type, E: type) type {
const tag = enum { ok, fault };
return union(tag) {
const Self = @This();
ok: T,
fault: struct {
reason: E,
message: []const u8,
maybe_allocator: ?std.mem.Allocator,
pub fn deinit(self: @This()) void {
if (self.maybe_allocator) |allocator| {
allocator.free(self.message);
}
}
},
fn succeed(value: T) Self {
return .{ .ok = value };
}
fn fail(pReason: E, pMessage: []const u8, pMaybe_allocator: ?std.mem.Allocator) Self {
return .{
.fault = .{
.reason = pReason,
.message = pMessage,
.maybe_allocator = pMaybe_allocator,
},
};
}
};
}
This is how an object incorporates it
const Object = struct {
const Self = @This();
const Error = error{ InvalidArgument, OutOfBounds, NoEnoughArguments };
const Ret = ReturnValue(Self, Error);
allocator: std.mem.Allocator,
// Example usage: a more practical case would have more complicated errors.
pub fn init(args_vector: []const [*:0]const u8, pAllocator: std.mem.Allocator) !Ret {
if (args_vector.len < 2) {
return .fail(Error.NoEnoughArguments, "Not enough arguments", null);
}
if (args_vector.len == 2) {
return .fail(
Error.InvalidArgument,
try std.fmt.allocPrint(pAllocator, "Invalid parameter: {s}\n", .{args_vector.ptr[1]}),
pAllocator,
);
}
return .succeed(.{ .allocator = pAllocator });
}
};
The thing that makes this feel like a failure is the overhead associated with using it, and also, propagating errors does not seem like it would be super streamlined.
const object = switch (try Object.init(init.minimal.args.vector, gpa.allocator())) {
.ok => |value| value,
.fault => |e| {
std.log.err("Error: {s}\n", .{e.message});
e.deinit();
return e.reason;
},
};
_ = object;
Does anyone have an idea on how to improve this rich error reporting mechanism? Or a better way of doing it?