Skip to content

ketexon/kser

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

7 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

KSer

Ketexon's serialization utility!

struct Player {
	kser::NamedField<int, "max_health"> max_health;
	kser::NamedField<float, "damage"> damage;
	int cur_health;
};

int main(){
	Player player {
		100,
		10.0f,
		50,
	};

	std::cout << "Has cur_health: " << kser::has_field(player, "cur_health") << std::endl;
	// Has cur_health: 0

	std::cout << "Has max_health: " << kser::has_field(player, "max_health") << std::endl;
	// Has cur_health: 1

	kser::set_value(player, "max_health", 120);
	std::cout << "Max Health: " << player.max_health.value << std::endl;
	// Max Health: 120

	using variant_t = std::variant<int, float>;
	auto fields = kser::get_value_map<std::map<std::string_view, variant_t>>(player);

	std::cout << "Damage: " << std::get<float>(fields["damage"]) << std::endl;
	// Damage: 10

	std::cout << kser::serialize_json(player) << std::endl;
	// {"max_health": 120, "damage": 10.00}

	return 0;
}

Introduction

KSer is a compile-time reflection utility that allows you to inspect types of structures, tag them with no-overhead names, and serialize them.

You could use this library to serialize arbitrary structs with very little overhead and no need to manually write serialization functions. Think of this as a proof of concept way to annotate fields (like Unity's [SerializeField]) and an implementation of serialization on top of that.

How it works

This is a proof of concept for basic reflection using these C++ features:

  • strings in templates arguments
  • structured binding pack
  • folding over lambdas

C++17's introduced structured bindings:

struct Struct {
	int x;
	std::string y;
	float z;
};
auto [a,b,c] = Struct { 1, "hi", 3.5f };

C++26's structured binding pack, which allows you to do this as a pack:

struct Struct {
	int x;
	std::string y;
	float z;
};

auto f(auto& s) {
	auto [...x] = s;
}

The only way to iterate over all values of a pack is a fold expression currently (you can access by index x...[0] since c++26, but you can't do this within a for loop since for loops aren't yet constexpr).

Thus, the only option is to use a templated function. And with templated lambdas to the rescue, we can use a fold expression over the pack with an immediately invoked lambda:

struct Struct {
	int x;
	std::string y;
	float z;
};

auto f(auto& s) {
	auto [...x] = s;
	([&] {
		std::cout << x << std::endl;
	}(), ...);
}

int main(){
	Struct s { 1, "hello", 3.5f };
	f(s);
	// prinst:
	// ```
	// 1
	// hello
	// 3.5
	// ```
	return 0;
}

Since this is a templated lambda, we can change the behavior based on the type to get a value of certain type and value conditions (with short circuiting to get the first of said values):

template<typename T>
T f(auto& s) {
	auto [...x] = s;
	T out;
	(... || [&] {
		if constexpr(std::assignable_from<decltype((out)), decltype(x.value)>) {
			out = x;
			return true;
		}
		return false;
	}());
	return out;
}

This is all we need, since now we can just create our own custom type specifically for serialization.

template<typename T>
constexpr std::optional<T> try_get_value(auto& s, std::string_view name) {
	auto& [...x] = s;
	std::optional<T> out;
	(... || ([&] {
		if constexpr (
			IsField<std::decay_t<decltype(x)>>
		) {
			if constexpr (std::assignable_from<decltype((out)), decltype(x.value)>) {
				if (name == x.field_name()) {
					out = x.value;
					return true;
				}
			}
		}
		return false;
	})());
	return out;
}

Another tool this uses is compile-time strings, which allows us to store the name of a field in its type and have it be 0 overhead by default.

template<size_t N>
struct StaticString {
	char value[N];

	constexpr StaticString(const char (&str)[N]) {
		std::copy(str, str + N, value);
	}

	constexpr std::string_view string_view() const {
		return std::string_view{value, value + N - 1};
	}
};

Usage

This project is header only. However, the kser/serialization.hpp headers refers to the kser/kser.hpp via #include <kser/kser.hpp>, so if you want to manually copy the headers, take that into account.

You can also use this as a CMake submodule via FetchContent:

include(FetchContent)
FetchContent_Declare(
	kser
	GIT_REPOSITORY https://github.com/ketexon/kser
	GIT_TAG main
)
# FetchContent_Declare(
# 	kser
# 	URL https://github.com/ketexon/kser/archive/heads/main.zip
# )
FetchContent_MakeAvailable(kser)

target_link_libraries(
	MyTarget
	PUBLIC
	kser
)

See examples.

#include <kser/kser.hpp>
#include <iostream>
#include <map>

struct MyStruct {
	kser::NamedField<int, "age"> age;
	kser::NamedField<std::string, "name"> name;
	kser::NamedField<float, "max_health"> max_health;
	float cur_health;
};

int main(){
	MyStruct s {
		21, 		// age
		"Aubrey", 	// name
		100.0f, 	// max_health
		50.0f,		// cur_health
	};

	// Get a field value by name
	auto age = kser::get_value<int>(s, "age");
	std::cout << "Age: " << age << std::endl;

	// Geting a field works for any assignable type
	// such as std::any, std::variant, or float
	auto name = kser::get_value<std::any>(s, "name");
	try {
		std::cout << "Name: " << std::any_cast<std::string>(name) << std::endl;
	} catch (const std::bad_any_cast& e) {
		std::cerr << "Bad any cast: " << e.what() << std::endl;
	}

	// if you want to get the field of the exact type, not convertible to,
	// use the strict mode (get_value_strict or get_value<T, true>)
	try {
		auto age = kser::get_value_strict<float>(s, "age");
	} catch (const kser::TypeMismatch& e) {
		std::cerr << "Type mismatch: " << e.what() << std::endl;
	}

	// this will throw an exception if the field is not found
	try {
		auto meow = kser::get_value<float>(s, "meow");
	} catch (const kser::FieldNotFound& e) {
		std::cerr << "Field not found: " << e.what() << std::endl;
	}

	// and fields not wrapped in NamedField are not accessible
	auto cur_health = kser::try_get_value<float>(s, "cur_health");
	std::cout << "Cur health found: " << cur_health.has_value() << std::endl;

	// there are also non-throwing versions with the prefix try_
	// you can also get a reference to the field itself
	// so that you can modify it directly

	// the non-throwing version returns an std::optional
	// of a std::reference_wrapper
	auto meow_opt = kser::try_get_field_with_name<int>(s, "meow");
	std::cout << "Meow found: " << meow_opt.has_value() << std::endl;

	auto age_opt = kser::try_get_field_with_name<int>(s, "age");
	age_opt->get().value = 23;
	std::cout << "New age after try_get_field_with_name: " << age_opt->get().value << std::endl;

	// Set a field value by name
	kser::set_value(s, "age", 22);
	std::cout << "New age after set_value: " << s.age.value << std::endl;

	// you can also get all values
	// this works by default for any container that
	// you can do v[key] = value; with
	using variant_t = std::variant<std::monostate, int, std::string, float>;
	auto values = kser::get_value_map<std::map<std::string_view, variant_t>>(s);

	std::cout << "Values map size: " << values.size() << std::endl;
	std::cout << "Age: " << std::get<int>(values["age"]) << std::endl;
	std::cout << "Name: " << std::get<std::string>(values["name"]) << std::endl;
	std::cout << "Max health: " << std::get<float>(values["max_health"]) << std::endl;

	// you can also set a bunch of values
	// this works by default for std::any, std::variant,
	// and any static_castable type.
	// a custom type TVal are supported too, but you need
	// to provide a custom Caster<TOut> that implements
	// - a default constructor
	// - TOut operator()(TVal map_value);
	// see AnyCaster, GetCaster, and StaticCastCaster
	std::map<std::string_view, variant_t> new_values {
		{"age", 95},
		{"name", "Bob"},
		{"max_health", 200.0f},
	};
	kser::set_values(s, new_values);

	std::cout << "New age after set_values: " << s.age.value << std::endl;
	std::cout << "New name after set_values: " << s.name.value << std::endl;
	std::cout << "New max health after set_values: " << s.max_health.value << std::endl;
	std::cout << "Cur health after set_values: " << s.cur_health << std::endl;

	// there is also templating visiting functionality
	auto visitor = [](auto& field) {
		using value_t = std::decay_t<decltype(field.value)>;
		std::cout << "Visiting field: " << field.field_name() << ", ";
		if constexpr (std::same_as<value_t, int>) {
			std::cout << "int field: " << field.value << std::endl;
		}
		else if constexpr (std::same_as<value_t, std::string>) {
			std::cout << "string field: " << field.value << std::endl;
		}
		else if constexpr (std::same_as<value_t, float>) {
			std::cout << "float field: " << field.value << std::endl;
		}
	};

	std::cout << "Visiting fields" << std::endl;
	kser::visit_fields(s, visitor);

	// you can also stop the visitor early by returning true
	auto visitor_early = [](auto& field) {
		using value_t = std::decay_t<decltype(field.value)>;
		std::cout << "Visiting field: " << field.field_name();
		if constexpr (std::same_as<value_t, int>) {
			std::cout << ", int field: " << field.value << std::endl;
			return true; // stop visiting
		}
		return false;
	};

	std::cout << "Visiting fields with early stopping" << std::endl;
	kser::visit_fields(s, visitor_early);

	return 0;
}

About

A no-overhead, compile-time, automatic reflection and serialization library for C++26

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors