🎉 Celebrating 25 Years of GameDev.net! 🎉

Not many can claim 25 years on the Internet! Join us in celebrating this milestone. Learn more about our history, and thank you for being a part of our community!

Checking if a type is defined

Started by
16 comments, last by Juliean 3 years, 2 months ago

@irreversal Interesting. I (un)fortunately don't do that much threading so I can save lots of complexity there. I recently made my bytecode-compiler able to be run in a thread, and the way I did that is by having a serial pass copying over all the data that is then be processed by the thread without the risk of anyone touching it :D (its not that bad since I can also transform it into a more efficient format in the same pass, but still).

I assume Load in your system is virtual in CSearchable, correct? In my own system, the actual loading/saving is more based upon reflection and written as an addon outside of the class:

void EntityIO::SaveComponent(yaml::MapNode& mComponent, const BaseComponent& component)
{
	core::AttributeIO::Save(component);
	// translates to something like:
	//
	// auto& mValues = yaml::MapNode::Add(mComponent, L"Attributes"); 
	// for (const auto& attribute : component.GetDeclaration().GetAttributes())
	// {
	// 	core::ConstVariableView value = attribute.GetValue(component);
	//
	//	core::AttributeIO::SaveValue(mValues, attribute.GetName(), value);	
	// }
	
	// do additional stuff which is not represented by data-members
}

Which is a bit different, and I think there is a merit and/or discussion to be had about both approachs (all features being part of the class itself VS features being built on top externally). I see pros and cons to both approaches, for example I can write, use and replace different forms of serialization (YAML, XML, binary) without having to touch the class itself. For that reason, I used to be way more towards not putting anything else than the basics into the class itself, but I've gone back on that restriction a while back, and now I do a mix of both based on what I see fit.

Advertisement

Juliean said:
I assume Load in your system is virtual in CSearchable, correct?

Not only - there's a bit more going on behind the scenes. Consider this (I've nipped most of the actual code out for clarity and omitted LoadProperties() and OnLoad()):


// provide private proxies that save/load everything in _this_ class 
#define CSEARCHABLE(...) \
    ... gore omitted ...\
	private:\
		ECBool SaveProperties(\
			_In_Modify ::markup::CTagFile& tagFile,\
			_Optional_In size_t rootId = size_t(~0)) const _Override { \
			return CSearchable::SaveProperties(tagFile, rootId); \
			}
			
			
class CSearchable
{
	private:
		ECBool SaveProperties(
			_In_Modify ::markup::CTagFile& tagFile,
			_Optional_In size_t rootId = size_t(~0)) const {
			// magic happens here: save attribs, properties and call Children>>SaveProperties().
			// Then invoke this class's top level handler:
 			EHCheck(OnSave(*tf, idThis));
 			return ECSuccess;
			}

	public:
		// provide optional save/load handlers, but make them optional
		
		_Basemethod
		ECBool OnSave(
			_In ::markup::CTagFile& tfProperties,
			_In uint32_t idParent) const {
			_Unref(tfProperties, idParent);
			return ECSuccess;
		}

};

For complex cases this might assume that the top level OnSave()/OnLoad() propagates the call if the top level class doesn't deal with its base class' custom stuff. Ideally you don't store custom stuff here at all, but everything is handle automatically.

Nevertheless, as it happens, there's a better way of doing this, which I'm using for my CSerialized class (again, routines related to deserialization and most of the gory details are omitted):

#define CSERIALIZABLE(ThisType) \	
	... gore ... \
	private:\
		template<bool Flag>\
		typename ::std::enable_if<Flag == true, ECBool >::\
		type SerializableOnSerializeHelper(\
			CSerializedWriter& writer) const {\
			return OnSerialize(writer);\
			}\
		template<bool Flag>\
		typename ::std::enable_if<Flag == false, ECBool >::\
		type SerializableOnSerializeHelper(\
			CSerializedWriter& writer) const {\
			_Unref(writer);\
			return ECSuccess;\
			}\
		ECBool Serialize(\
			CSerializedWriter& writer) const _Final {\
			return SerializableOnSerialize(writer);\
		}\
	public:\
		ECBool SerializableOnSerialize(\
			CSerializedWriter& writer) const _Override {\					
			EHCheck(_ThisType::SerializableOnSerializeHelper<::muon::has_serializer_funcs<_ThisType>::value>(writer));\
			... serialize automatic vars here ... \
			if constexpr(has_super_v<_ThisType>)\
				{ return has_super<_ThisType>::_SuperType::SerializableOnSerialize(writer); }\
			else\
				return ECSuccess;\
		}
	
class _InterfaceClass ISerialized {
	public:
		// force the derived class to have a valid CSERIALIZABLE() as these are implmented
		// by that macro
	
		_PureBasemethod
		ECBool Serialize(
			CSerializedWriter& writer) const = 0;

		_PureBasemethod
		ECBool Deserialize(
			const CSerializedReader& reader) = 0;	
};

class CSerialized // <- NOT derived from ISerialized in order to better support multiple inheritance
{
	protected:
		// these functions are implemented by the CSERIALIZED() macro for each class

	
		_PureBasemethod
		ECBool SerializableOnSerialize(
			CSerializedWriter& writer) const = 0;

		_PureBasemethod
		ECBool SerializableOnDeserialize(
			const CSerializedReader& reader) = 0;

	public:
		// these functions may be override by each level of the inheritance tree to save/load custom data
	

		_Basemethod
		ECBool OnSerialize(
			CSerializedWriter& writer) const {
			_Unref(writer);
			return ECSuccess;
		}

		_Basemethod
		ECBool OnDeserialize(
			const CSerializedReader& reader) {
			_Unref(reader);
			return ECSuccess;
		}
};

Note that these are snippets of an unfinished framework, which hasn't been properly tested so there may be some oddities here and there.

In this scheme a class' “registered” variables (attribs, properties, etc) are automatically serialized. If the class contains some complex custom member objects, however, it can override to OnSerialize()/OnDeserialize() and handle them there. Where it becomes more intuitive than the scheme used for CSearchable is if different classes in one heritance tree need to serialize different “local” objects. This solution ensures that you cannot forget to serialize the base class(es). Consider:

class MyBase 
	: public CSerialized, public ISerialized // <- need to derive from both in this case (see previous snippet)
{
	public: 
		CSERIALIZABLE(MyBase); // <- ugly bits are hidden here

		MyDataStore* store; // some complex object that cannot be trivially serialized
		
		ECBool OnSerialize(CSerializedWriter& writer) const _Override {
			EHCheck(Helper_SerializeComplexStore(writer, *store)); // <- some magic black box 
			return ECSuccess;
		}
		
		...
};

class MyDerived
	: public MyBase
{
	public: 
		CSERIALIZABLE(MyDerived);

		MyComplexCatModel scottishFold; // another complext object; in this case a _very_ complicated cat
		
		ECBool OnSerialize(CSerializedWriter& writer) const _Override {
 			EHCheck(Helper_SerializeComplexCat(writer, scottishFold));
 			// NOTE: not propagating the call to the base class!
			return ECSuccess;
		}
		
}

Here OnSerialize() is called for all levels in the hierarchy without any attention from the programmer, starting with MyDerived (note that - if there were another class between MyBase and MyDerived, which did not need to be serialized, it would be skipped). It's invoked simply as:

MyDerived myderived;
CSerializedWriter writer;
myderived.Serialize(writer);

// 1. MyDerived::Serialize() calls MyDerived::SerializableOnSerialize()
// 2.1 this chooses whether to invoke MyDerived::OnSerialize() if both OnSerialize() AND OnDeserialize() are implemented (note that if either is present, then it's not valid and therefore becomes impossible to not to implement the other)
// 2.2 then serializes all automatic variables for MyDerived
// 2.3 finally, if MyDerived has a super class (I'm keeping track of class hierarchy separately - all of that code is omitted, but it becomes easy once you already have a macro which takes the this class' name), calls:
//		_SuperType::SerializableOnSerialize(), which in this case happens to be MyBase::SerializableOnSerialize()
// we're back in point 2.1 now, so rinse and repeat until the hierarchy has been walked

Basically this implements a hidden top-down call tree, which checks if a given class implements both of the OnXXX() functions and calls them if needed. This ensures that things are always serialized and deserialized in the same order, that all objects are both written and read, provides an automatic framework for 90-100% of the writes/reads and overall minimizes a potential for mistakes by a huge margin.

As for serialization itself - CSerializedWriter and its cousin CSerializedReader can be made implementation-independent - the output COULD be binary, text, YAML, markup or whatever you want. In my case I've separated XML/JSON and other markup styles into the CSearchable and binary data into CSerialized. This just made sense to me as I won't want to expose binary objects (e.g. everything) to searching or an editor, but I do want to store attributes and properties in human-readable format in the form of settings. Also, this way I can parallelize stuff more easily.

My main objective is eventual simplicity hidden behind a (fairly complex) wrapper and, more importantly, reliability. If at all possible, I don't want to worry about saving and loading data beyond indicating “this needs to be saved”.

Juliean said:
I see pros and cons to both approaches, for example I can write, use and replace different forms of serialization (YAML, XML, binary) without having to touch the class itself.

The bottom line is: there's really no way of not touching the class itself. Either you end up over-complexifying your I/O routines to handle arbitrary classes (and sooner or later this will fall apart) or you need to provide (possibly extensive) metadata about serialized objects. Either way there's meddling involved. A while back I tried going with a handler-based system (e.g. each type has a type handler and serialized fields are exposed externally), but ended up in a handler hell. Plus, I kept forgetting to add attribs to the registrar. This is why I prefer my metadata to be clear and confined within the class itself: for instance, if a field is a non-dependent (e.g. not derived or dependent on the session state), then it can be an attribute. My objective is to make it as easy as possible to indicate that fact that something is an attribute without getting into external tools or dealing with the possibility of forgetting something somewhere.

By the way - I'm on C++17 and haven't looked into concepts at all. If you do end up skimming through the code, let me know if concepts might provide an easier solution for any of this?

Anyways - sorry for the long post ?.

irreversible said:
By the way - I'm on C++17 and haven't looked into concepts at all. If you do end up skimming through the code, let me know if concepts might provide an easier solution for any of this?

Yeah, sure! For example, the SerializableOnSerializeHelper could be rewritten to this:

template<bool Flag> requires (Flag == true)
ECBool SerializableOnSerializeHelper(
			CSerializedWriter& writer) const {
			return OnSerialize(writer);
			}
			
template<bool Flag> requires (Flag == false)
ECBool SerializableOnSerializeHelper(
	CSerializedWriter& writer) const {
	_Unref(writer);
	return ECSuccess;
	}

Pretty much any time where you would have used SFINEA/enable_if, you can use a “requires”-clause. Though truthfully, even with c++17 you could have just used constexpr-if, no?

template<bool Flag>
ECBool SerializableOnSerializeHelper(CSerializedWriter& writer) const
{
	if constexpr (Flag)
		return OnSerialize(writer);
	else
	{
		_Unref(writer);
		return ECSuccess;
	}
}

Thats what I've been doing for a while now, before concepts were introduces.

irreversible said:
The bottom line is: there's really no way of not touching the class itself. Either you end up over-complexifying your I/O routines to handle arbitrary classes (and sooner or later this will fall apart) or you need to provide (possibly extensive) metadata about serialized objects. Either way there's meddling involved. A while back I tried going with a handler-based system (e.g. each type has a type handler and serialized fields are exposed externally), but ended up in a handler hell. Plus, I kept forgetting to add attribs to the registrar. This is why I prefer my metadata to be clear and confined within the class itself: for instance, if a field is a non-dependent (e.g. not derived or dependent on the session state), then it can be an attribute. My objective is to make it as easy as possible to indicate that fact that something is an attribute without getting into external tools or dealing with the possibility of forgetting something somewhere.

Yeah, I can see that, and I kind of also came to the conclusion that not touching the class is not a good solution eigther. But I still hugly prefer the ability to write most of what I want as an extension with my (arguably very fast; compared to **** like C#) reflection-based approach. For example, large parts of my editor are written with this in mind. This allows me to implement features like undo, overrides, multi-select etc… all in isolation without bloating the code-base itself. Thats why I would, at the very least always go with a system that exposes the attributes in a way that I do, but that might just be personal perference. Would this in your solution all be part of the interface/class? I guess the macro/code-generation-part could/would handle that, but I had a very bad experience in the beginning when I didn't have my current system in place and pretty much every new class that I had to add required tons of code to be written; and subsequentially every new feature had also be adopted all over the place.
I also don't really agree with the conclusion about overcomplexifying the IO-routines, and/or that it eventually falls apart. Maybe thats for the fact that my routines are not really generic in most ways (only “attributes” are handled automatically, most other things are written explicitely for the specific type), but the system works pretty well for a few years now. I did initially hit a bump when I introduced the system at first, but over time things didn't fall apart but rather smooted out, thanks to lots of security/validation-features and stuff like having natvis-visualisation makes it pretty easy to use/debug now. My point being, I'm definately doing something right here, even if I agree that there are potential pitfalls in this approach (but what doesn't?)

Thanks btw for sharing all your code, always interesting to see how other people handle things. like that

Juliean said:
Though truthfully, even with c++17 you could have just used constexpr-if, no?

I had to go back and check why I didn't do it like that and I think it might have to do with VS' hilighting, which can't chew through the if constexpr. Although I could swear there was some other reason as well.

Juliean said:
Would this in your solution all be part of the interface/class? I guess the macro/code-generation-part could/would handle that, but I had a very bad experience in the beginning when I didn't have my current system in place and pretty much every new class that I had to add required tons of code to be written;

That one macro (CSERIALIZABLE() or CSEARCHABLE()) is all that's needed for any and all setup related to these particular aspects of the class. I've spent quite a bit of time tinkering with metaprogramming to get it to this point, though, so the time cost is definitely there.

There are uglier sides to this, however. For instance, I actually set up all my type-related reflection in a separate macro, which assumes that the object is derived from a common base, CKnownObject. This builds a whole derivation tree and stores it into a CClass object, which I can use to create the object, get its traits or get access to its metadescriptor, CDataType. All of this is non-intrusive and works great, even with multiple inheritance.

That is, until templates become involved. Suddenly this one basic macro, CIMPLEMENTATION(), needs to be specialized for template classes based on template classes, for non-template classes based on template classes, etc. In addition, while still not to weighty, CSERIALIZABLE() and CSEARCHABLE() bring with them a cost (several vectors the case of a searchable), which means that pushing all of this into a common base class can be a really bad idea. After all, that per-object properties and child info needs to be stored somewhere.

Furthermore, a class may or may not be based on CKnownObject, may or may not be searchable and may and may not be serializable. If you combine these three + whatever permutations of the CIMPLEMENTATION() macro there are for various template classes, you'll rack up a reasonably large collection of macros. For this reason, I'd love to not have macros involved, but CRTP can't handle most of this stuff either.

This is the primary place where some sort of non-intrusive UE-style external parser wins hands down - it's just that much cleaner.

Other than that these three macros are currently the ones that give me full control over a class. One more thing I'm planning to implement the same way is CUndoable, which does add another initialization macro to a class that derives from it.

Juliean said:
(only “attributes” are handled automatically, most other things are written explicitely for the specific type)

Incidentally - I'll share a bit of insight I discovered when it comes to handling type conversion (e.g. from variable to serialized blob or from variable to text, or the other way around). I had quite a few types that I wanted to express as text, each of which required some form of conversion routine, which I was struggling to standardize.

Finally, while working on my logging code, it hit me that I could just implement global << and >> operators for to- and from-text conversion. Technically this could work the same for serialization.

Juliean said:
Thanks btw for sharing all your code, always interesting to see how other people handle things. like that

Indeed!

I've also grown more confident about my code over time as more and more issues have been worked out. In my case there's been a lot of uncertainty and doubt when it comes to (re)implementing large components like these and there's definitely solace in seeing that things can be solved very differently and they still work out. Sometimes, for the longest time, what all the big engines are doing can seem really opaque ?.

irreversible said:
I had to go back and check why I didn't do it like that and I think it might have to do with VS' hilighting, which can't chew through the if constexpr. Although I could swear there was some other reason as well.

Intellisense can be a bit wonky at times, yeah, and it usually takes a few years (!) for new features to fully work with it. So if it has been a while since you last tried, I would give it another go as the c++17-support for intellisense in general seems to work flawlessly now. Ironically I have the same problem at the moment with concepts where I can use it all the time because it will fuck up Intellisense at certain points.

irreversible said:
There are uglier sides to this, however. For instance, I actually set up all my type-related reflection in a separate macro, which assumes that the object is derived from a common base, CKnownObject. This builds a whole derivation tree and stores it into a CClass object, which I can use to create the object, get its traits or get access to its metadescriptor, CDataType. All of this is non-intrusive and works great, even with multiple inheritance. That is, until templates become involved. Suddenly this one basic macro, CIMPLEMENTATION(), needs to be specialized for template classes based on template classes, for non-template classes based on template classes, etc. In addition, while still not to weighty, CSERIALIZABLE() and CSEARCHABLE() bring with them a cost (several vectors the case of a searchable), which means that pushing all of this into a common base class can be a really bad idea. After all, that per-object properties and child info needs to be stored somewhere.

Truth be told, most of my types don't even use inheritance at all, so thats even a step further than what I usally have to deal with. My approach usually just uses one vector, which I move into a separate “declaration” class whose pointer is stored in the base (though implementations are free to also use a global storage with an index or something if necessary). So in that way the cost for the actual class itself is a lot smaller, also this work only has to be done once per type and not once per clas s(which it seems thats whats happening on your end).

irreversible said:
Incidentally - I'll share a bit of insight I discovered when it comes to handling type conversion (e.g. from variable to serialized blob or from variable to text, or the other way around). I had quite a few types that I wanted to express as text, each of which required some form of conversion routine, which I was struggling to standardize. Finally, while working on my logging code, it hit me that I could just implement global << and >> operators for to- and from-text conversion. Technically this could work the same for serialization.

The real kicker for me was that in Unreal, they went even one step further and basically did this (for serialization):

void Serialize(Stream& stream) const noexcept override
{
	value << stream;
}

Where the punchline is, that this function is called both for writing and reading. Now while this doesn't work for string-conversions, the fact of simply using one operator and not having separate operators is intriguing (even though I'm still to this day not 100% sure if I like it - it has limitations and kind of appears smelly to me; but interesting nevertheless.

irreversible said:
I've also grown more confident about my code over time as more and more issues have been worked out. In my case there's been a lot of uncertainty and doubt when it comes to (re)implementing large components like these and there's definitely solace in seeing that things can be solved very differently and they still work out. Sometimes, for the longest time, what all the big engines are doing can seem really opaque ?.

I luckly managed to overcome the initial uncertainties and analysis-paralysis phases when writing large systems like those quite early on. I usually just do something, and lucky for me I usually have a good rate of success, even if I end up refactoring a lot (which I generally see as a good thing anyway). Especially since I've started working professionally with Unity, I've become proud of what I've achievemed myself. When I compare some of the shit that is going on in the Unity-ecosystem, what I'm doing seems really good compared to it. Maybe its just the fact that I have full source-control, which definately helps compared to having to implement editor-utility etc… in unity - but still, some of the things there are really really bad IMHO.

Juliean said:
So if it has been a while since you last tried, I would give it another go as the c++17-support for intellisense in general seems to work flawlessly now.

Oh, I'm on 17. I haven't moved to 20 (and hence don't have experience with concepts) because I purposefully refuse to be an early adopter. In my book this has always rung true for Microsoft and Adobe ?.

Juliean said:
most of my types don't even use inheritance at all

But if you're writing a library, can you expect that to hold true? Or is it an in-house thing?

Juliean said:
So in that way the cost for the actual class itself is a lot smaller, also this work only has to be done once per type and not once per clas s(which it seems thats whats happening on your end).

I'm mapping only the attribs statically. For children and properties I still need storage, which is going to take up space no matter what. I could conceivably allocate the storage block separately, I guess, but that would simply add another 8/16 bytes to classes that make use of properties.

Juliean said:
The real kicker for me was that in Unreal, they went even one step further and basically did this (for serialization): void Serialize(Stream& stream) const noexcept override { value << stream; }

Hm. For a sec I actually thought “oh, that's pretty clever”, but then I lost track of what the actual benefits are ?. I seems to me it makes little difference at the end of the day.

As for calling it for both reading and writing: I don't know, I like clarity. << for writing and >> for reading has been established since, what, the 80s? I also purposefully do not provide stream operators for my serializer classes because I'm already using those for text-related operations.

Juliean said:
I end up refactoring a lot (which I generally see as a good thing anyway)

For large components (logging, serialization, etc.) I've noticed roughly a 4-year cadence. My current type-management system has been in the works for 3 years now, though, so I'm SERIOUSLY hoping to nail it hard enough that I don't need to rip it up again.

Juliean said:
When I compare some of the shit that is going on in the Unity-ecosystem, what I'm doing seems really good compared to it.

Yeah, this is one of those weird things. For myself, I'm a hobbyist who's made the conscious and objectively stupid decision of staying away from existing engines and their ecosystems. But I know the feeling of “OK, I made this and it actually works (?)” (always with a tiny question mark at the end) and I like it a lot. It's both a blessing and a curse to not have to deal with others' wonky code.

irreversible said:
Oh, I'm on 17. I haven't moved to 20 (and hence don't have experience with concepts) because I purposefully refuse to be an early adopter. In my book this has always rung true for Microsoft and Adobe ?.

I personally like to try out new features ASAP, but it totally get why one wouldn't. I'm stuck with 16.7 now for a while since I can't compile my codebase with newer versions despite already having a submitted a ticket that was deemed “fixed” a few months ago. Not a pleasent experience.

irreversible said:
But if you're writing a library, can you expect that to hold true? Or is it an in-house thing?

I mean, in practice I'm the only one to be working with my codebase for the forseable future. II'm working on a game-engine where users code theoretically write their own c++-plugins (which is what I do myself for parts of the codebase). But'm nowhere near ready to have release it to the public (mostly because I'd need to spend half a year writing documentation).
Thats why I'm willing to compromise on certain things that might not hold true in a more general context. In certain aspects I can absolutely be sure that (multiple/multi-layer) inheritance is not a thing - ie. my ECS doesn't support inheritance for components on a conceptual level (which is where the type-system takes hold), but you could theoretically inherit systems. In other parts of the code I deliberately make use of CRTP to spare me of having to implement the same 5 base-methods all over the place, even though it prevents inheritance. Or using CRTP to generate an integer-ID that I can use to index classes and not have to look them up in a map. I prefer to save myself time over support any possible use-case.

irreversible said:
Hm. For a sec I actually thought “oh, that's pretty clever”, but then I lost track of what the actual benefits are ?. I seems to me it makes little difference at the end of the day.

The main benefit is that you only have write one serialization-function for both reading and writing. Not sure, it seems your code also does this? Personally I've always had separate Read/Write-routines, so the idea of just having one seemed to be nice at least in terms of effort required and code-duplication.

irreversible said:
Yeah, this is one of those weird things. For myself, I'm a hobbyist who's made the conscious and objectively stupid decision of staying away from existing engines and their ecosystems. But I know the feeling of “OK, I made this and it actually works (?)” (always with a tiny question mark at the end) and I like it a lot. It's both a blessing and a curse to not have to deal with others' wonky code.

Definately. I've had many times at work now were we had some serious issues that I could not fix because the SDK of company XYZ had some internal bug. But also it takes a lot of time to write an engine entirely from scratch, and if I hadn't been so goddamn lazy and minimalistic during my school/study time I wouldn't have gotten anywhere near where I'm now.

This topic is closed to new replies.

Advertisement