Design thoughts for compatible classes with different variable sizes?

Started by
8 comments, last by dmatter 10 years, 6 months ago

I have a class, and its interface looks like this:


struct cRect;
class Serializer;

struct cPoint
{
public:
    //I named these 'value_type' in preparation of templating the type, but I haven't templated it yet. 
	typedef int value_type;
	value_type x;
	value_type y;

public:
	cPoint() : x(0), y(0) { }
	cPoint(value_type x, value_type y) : x(x), y(y) { }
	explicit cPoint(const std::string &str);
	cPoint(const cPoint &point);
	~cPoint() = default;

	//Keeps the point within 'rect'.
	void KeepWithin(const cRect &rect);
	//Keeps the point outside of 'rect'.
	void KeepWithout(const cRect &rect);
	//Snaps the point to the nearest edge of 'rect', regardless of
	//whether the point is inside or outside the rectangle.
	void SnapToEdge(const cRect &rect);

	//Returns the absolute distance between this point and 'other'.
	unsigned DistanceFrom(const cPoint &other);
	//Returns true if we are within 'distance' of 'other'. This is faster than 'DistanceFrom', because it saves a sqrt().
	bool WithinDistanceOf(const cPoint &other, unsigned distance) const;

	//Returns 'true' if 'value.x' is greater than 'min.x' and less than 'max.x', and the same for the 'y' member.
	static bool IsWithin(const cPoint &min, const cPoint &value, const cPoint &max);
	static bool IsWithin(const cPoint &value, const cPoint &max);
	//Keeps 'value.x' between 'min.x' and 'max.x', with 'loops.x' being the number of times it had to loop around.
	//Does the same with the 'y' member. Negative loops return a negative number.
	static cPoint LoopInRange(const cPoint &min, const cPoint &value, const cPoint &max, cPoint &loops);
	static cPoint LoopInRange(const cPoint &value, const cPoint &max, cPoint &loops);

	bool operator==(const cPoint &other) const;
	bool operator!=(const cPoint &other) const;

	cPoint &operator=(const cPoint &other); //Assignment operator

	cPoint &operator+=(const cPoint &other);
	cPoint &operator-=(const cPoint &other);
	cPoint &operator*=(const cPoint &other);
	cPoint &operator/=(const cPoint &other);
	cPoint &operator%=(const cPoint &other);

	cPoint operator+(const cPoint &other) const;
	cPoint operator-(const cPoint &other) const;
	cPoint operator*(const cPoint &other) const;
	cPoint operator/(const cPoint &other) const;
	cPoint operator%(const cPoint &other) const;

	//Additive-inverse operator.
	cPoint operator-() const;

	//Packs the cPoint into a Uint32, with 16 bits for x, and 16 for y. Since both x and y can be negative,
	//this leaves just 15 bits for the numeral component, meaning (-32768 to 32768) in both x and y.
	uint32_t ToUint32() const;
	void FromUint32(uint32_t data);

	//Format: "(x, y)"
	std::string ToString() const;
	void FromString(const std::string &str);

	void Serialize(Serializer &serializer);

	SFML_ONLY
	(
		sf::Vector2f ToSfmlVector2f() const;
		void FromSfmlVector2f(sf::Vector2f vector);
	)

	QT_ONLY
	(
		QPoint ToQPoint() const;
		void FromQPoint(QPoint point);
	)
};

(Note: the 'c' in 'cPoint' doesn't stand for 'class' - it's to indicate it's part of my 'Common' code base, and so is a namespace-like prefix, but since the class is so commonly used, I don't want to always call it 'Common::Point' like I do for some of my other classes)

I have similar classes called cSize, and cRect. I'm happy with the current usability of all three (point, size, and rect). However, I find I have a need to compress the size of these classes down in rare circumstances.

I'd like to have a cPoint16 and a cSize16 class - where both the x and y member of cPoint16 would be an int16_t so the entire class would fit into four bytes instead of eight. I understand there'll be some tiny hit to performance, but performance isn't yet a bottleneck in this area, but RAM looks like it might soon become one.

To prevent redundant code, I was thinking about making cPoint templated on the integer type of the x and y components, so I'd probably go:


typedef Common::Point<int32_t> cPoint;
typedef Common::Point<int16_t> cPoint16;

This would also allow me the (likely to be called upon) future flexibility of: Common::Point<float>

I'd like this possible cPoint16 class to be compatible in comparison with the regular cPoint.

That is, I'd like to be able to do things like: cPoint16 == cPoint, cPoint += cPoint16, and etc...

This does open me up to potential int overflows, but that's something that's also true with normal uint16_t / uint32_t operations, and is something one naturally has in the back of their mind when they are directly working with variables of differing sizes - and with the cPoint16 naming, I think it'd be fairly apparent to me and probably won't be much of an issue in practice (I could even use asserts to validate results to check for overflows when in debug mode).

Because of this interoperability, cPoint operations might actually have to be templated as well.

This:


cPoint &operator+=(const cPoint &other);

Might need to become something like this:


template<typename OtherPointType>
cPoint &operator+=(const OtherPointType &other);

Which opens me up to potential problems of 'OtherPointType' not even having to be remotely the same type, as long as it has an 'x' and a 'y' member, which would be weird.

I could staticly assert that ThisPointType and OtherPointType both have integer or both have floating point values, and block mixing and matching of floating point and integers, while still allowing mixing and matching of various sizes of integers or sizes of floats.

If I wanted to semi-ensure both point types are actually a Common::Point templated class, I enable_if() the operators based on the existence of some private non-functional function or typedef, or something like that.

It'd be easy to just say that cPoint and cPoint16 shouldn't be compatible with each other, but I think their compatibility would be very convenient and, while not necessary, something that would be used alot - bearing in mind that cPoint16 is not compact for serialization, but compact for active/persistent usage when a hundred thousand structs are active. The cPoint16 instances would almost always be added to regular cPoints.

There's also the precedent of interoperability of ints that are different sizes - as much as I might suggest to myself to never mix operations of different size ints, that's a good guideline most of the time, but there are frequent situations where they eventually do need to be added together with the final result.

Anyway, I'm interested in hearing your code design thoughts on this matter.

Advertisement

If I wanted to semi-ensure both point types are actually a Common::Point templated class, I enable_if() the operators based on the existence of some private non-functional function or typedef, or something like that.


Why not this:


template <typename OtherT>
Point &operator+=(const Point<OtherT> &other);

Now it has to be a Common::Point.

You might also use enable_if to ensure T and otherT are both integer types (as opposed to integer and float, for example).

Good solution! I hadn't thought of that.

[source]
template<typename T>
struct Point2
{
template<typename S>
inline Point2<T> operator + (const Point2<S>& b) const
{
return Point2<T>(x + b.x, y + b.y);
}
T x;
T y;
};

[/source]

\edit

beaten to it.

If I wanted to semi-ensure both point types are actually a Common::Point templated class, I enable_if() the operators based on the existence of some private non-functional function or typedef, or something like that.


Why not this:

template <typename OtherT>
Point &operator+=(const Point<OtherT> &other);
Now it has to be a Common::Point.

You might also use enable_if to ensure T and otherT are both integer types (as opposed to integer and float, for example).


Another possible variation would be to remove the operator completely and go with possibly insane generality:


template< typename LhsType, typename RhsType > inline
LhsType& operator +=( LhsType& lhs, Point2< RhsType >& rhs )
{
// Viable version of code here.
}

template<> inline
cPoint16& operator +=( cPoint16& lhs, Point2< float >& rhs );
// Link error if someone tries..
Not really that much different but gives you more options to control acceptable variations and you can place all the desired variations in a single file somewhere.

If performance isn't an issue, why not add explicit casts between the classes? All operations are automatically compatible and by requiring explicit casting, the code will always highlight locations where you need to pay attention to type sizes.

f@dzhttp://festini.device-zero.de

Another question, in the same context: What makes more sense in naming?

If I had (to simplify the question):


struct cPoint<Type>
{
     Type x, y;
};

Should cPoint<int16_t> be named cPoint16 because the values it contains are 16 bits, or should it be named cPoint32 because Point as a whole fits into 32 bits?

So a regular cPoint<int32_t> would be named cPoint64 or cPoint32. On the other hand, a cPoint<float> and cPoint<double> would be named cPointF and cPointD (if I ever bothered to define and use them), or should I do cPointF32, cPointF64. Or maybe cPoint32f, or cFPoint32...

What would be your suggested naming scheme?

Also, out of curiosity, when you see something like uint32_t, do you think of those 32 bits in terms of, "this integer can hold a value of up to 2^32" (do you see the 32 bits in terms of the range/precision of the value), or do you think, "this integer fits into 32 bits of memory" (do you see the 32 bits primarily in terms of storage)?

Obviously you are aware of both, but what strikes your mind first?

Normally, I just use 'ints' and 'unsigned' when I need general values, and only start using 'int32_t', 'int16_t', 'uint32_t', etc... when I need (or am at least recommending to the compiler) a specific bit size, usually for storage purposes. However, when I need a larger integer size, I go for uint32_t by habit (forgetting/ignoring unsigned long long), so I guess I'm a bit inconsistent with that!

What would be your suggested naming scheme?


I think I'd rather the typedef reflected how the template was parameterised, i.e. cPoint16 for a cPoint<int16_t> and cPointF for cPoint<float>.

One thing to think about is whether you really need to typedef all the variants, you could just typedef the common case and leave the others for explicit template instantiation.

Also, out of curiosity, when you see something like uint32_t, do you think of those 32 bits in terms of, "this integer can hold a value of up to 2^32" (do you see the 32 bits in terms of the range/precision of the value), or do you think, "this integer fits into 32 bits of memory" (do you see the 32 bits primarily in terms of storage)?
Obviously you are aware of both, but what strikes your mind first?

Tough question because, as you say, you have to be aware of both. I suppose since maximum value is bounded by the storage then thinking about the storage first and maximum value second would make the most sense.

Ideally if someone were interested in the maximum value rather than the storage, then they would use the 'fast' types like int_fast32_t. I rarely see them used though.

I think I'd rather the typedef reflected how the template was parameterised, i.e. cPoint16 for a cPoint<int16_t> and cPointF for cPoint<float>.

That's what I was leaning too myself - it just seemed more intuitive to me, though I'm not sure why.

Logically, since I think of uint16_t as '16 bits' not 'holding 2^16 values', it seems like I should go with cPoint<int16_t> = cPoint32 as '32 bits total', not cPoint16 as 'holding values that 16 bits allow', but it feels off, for some reason. The name is just unintuitive to me.

One thing to think about is whether you really need to typedef all the variants, you could just typedef the common case and leave the others for explicit template instantiation.


Yeah, I'll likely only typedef the ones I use regularly, and have commented-out typedefs that I can uncomment if their use starts to become common (this is just so I can remember what naming scheme I chose for them).

Also, out of curiosity, when you see something like uint32_t, do you think of those 32 bits in terms of, "this integer can hold a value of up to 2^32" (do you see the 32 bits in terms of the range/precision of the value), or do you think, "this integer fits into 32 bits of memory" (do you see the 32 bits primarily in terms of storage)?
Obviously you are aware of both, but what strikes your mind first?


Tough question because, as you say, you have to be aware of both. I suppose since maximum value is bounded by the storage then thinking about the storage first and maximum value second would make the most sense.

Ideally if someone were interested in the maximum value rather than the storage, then they would use the 'fast' types like int_fast32_t. I rarely see them used though.

I don't think I've ever used those, or the int_least32_t.

I'll likely name cPoint<int> as just cPoint (instead of cPoint32), letting it just be whatever makes sense to the compiler.

Thanks for the feedback!

[Edit:] Hadn't noticed the malformatting - fixed it.

it feels off, for some reason. The name is just unintuitive to me.


I can provide some alternative reasoning for my previously suggested names, see what you think...

Perhaps the reason the naming feels a bit awkward is because the size (number of components) of cPoint is implicitly 2 and the fact that it has integer components is also implicit.

In the past, I've seen names like this:

vec2i
vec2f

Then for unsigned types:

vec2ui
vec2ul

Building on that kind of convention for fixed-width types:

vec2i16
vec2i32

Which can be thought of a as a vector of 2x int16's or int32's respectively. So the overall size is just the mathematical expression as read: 2x16 or 2x32.

So you might call yours:

cPoint2i16
cPoint2i32

Now, if you want to make bits of that name implicit with the cPoint type then I don't see why the other parts of the name should necessary have to change meaning.

Say that cPoint simply is defined as being a 2-vector, we might remove the sizing from the name (replacing with an underscore just for readability):

cPoint_i16
cPoint_i32

And if you want integer types to be default/implied then we can remove that too (and collapse the underscore now) which brings us back to:

cPoint16
cPoint32

The meaning of the numbers hasn't changed at all.

Similarly, with a cPointF, that doesn't mean the entire cPoint fits into a single float, it is just shorthand for (an imaginary) cPoint2F, which itself would just be shorthand for: Common::Point<2, float>

This topic is closed to new replies.

Advertisement