Class design, kinda math specific.

Started by
13 comments, last by GameDev.net 11 years ago

Hi folks, continuing on with re-evaluating a bunch of different things in code as preparation to write another article, a fun one came up which actually someone asked a related question about involving statics. While the question was just "why" you would use it, it applies to the bit I'm considering at this moment. This may be better suited for the math forum's as it is related to math classes but it really is a general programming topic in terms of good class design. Anyway, the basic idea is picking on a single function in a 2d or 3d vector class and considering the ups/downs and possible use cases for the function. While not absolutely critical, I've been re-evaluating it in regards to C++11 (not much change) and common bug scenarios, plenty. The function is simple: "Normalize".

OK, many may be asking just how complicated could "Normalize" be to write. Well, surprisingly complicated if you want to avoid the most likely set of bug producing use cases. I normally re-evaluate how I code every couple years in order to break bad habits I've fallen into (hence the question about includes using brackets versus quotes, seems I'm doing OK in reality) and to see if I've learned anything over that time which could be applied and made better. (Hopefully forming a new habit.) In this case I was working up the preliminaries to extending on the articles I've been writing by attacking a cross platform vectorized math library using SSE and Neon as targets with a fallback to non-vectorized code as required. (I.e. SSE is pretty much a given anymore, Neon is most definitely not.)

Anyway, let's produce some use cases and questionable results:


Vector3f  toTarget = target - source;
Vector3f  upVector = Vector3f::kYAxis;
Vector3f  rightVector = toTarget.Normalize() ^ upVector;

Make two assumptions from here out please: toTarget is not nearly equal to upVector and unit vector is give or take a certain amount of error around 1.0f. Otherwise I'll be describing possible edge cases more than getting to the point... smile.png

Ok, so assuming I got the cross product order correct (I ALWAYS forget... smile.png) I should end up with unit vectors of up and right. Some libraries I've worked with would have a side effect, toTarget would have been normalized in that code. I despise side effects so any behavior which ends up with toTarget being normalized is out the door, so if I want this code to work as written the signature has to be: "Vector3f Normalize() const". It could be non-const but that defeats self documentation in saying *this* is not changed after the call, so non const is also out the door.

Second use case is a two parter.


Vector3f incidentVector = -toTarget.Normalize();

Again, no side effects allowed so the above change works and we get the negated unit vector as desired. Now, in "documenting" code I often break up equations, this would not normally be a candidate but assuming this were some big nasty equation with lots going on that needs to be explaining, I may break the code into the following pieces:


Vector3f incidentVector = -toTarget;
incidentVector.Normalize();
Do you see the bug? I have done this sort of thing many times over the years and seen many others do the same thing. The normalize call no longer "does" anything since it is const and not modifying "this" without an assignment is a nop. Some compilers will give you warnings in this case and prevent you hunting the bug, but not all notice it. Not to mention a lot of code bases turn off "unused return value" warnings since there are a pretty numerous set of cases where it is perfectly valid to ignore return values.
This leads to two conflicting desires, one is to maintain easy to use code and the other is to prevent silly errors such as case two.
So, I was thinking I'd use the following signatures:

class Vector3f
{
public:
   void  Normalize();
   friend Vector3f Normalize( const Vector3f& );  // Optional, could just have the helper
  // defined as: { Vector3f result = in; result.Normalize(); return result; }  which most
  // optimizers with do well with.  (NOTE: C++11 I believe the return would be a &&,
  /// still exploring that bit in detail as part of my re-evaluation..)
};
Given this you have to modify the first use case to use "Normalize( toVector )" since there is no return to work on from the member function. The second use case works as intended and the easy to refactor into a bug issue goes away.
Now, where all this long winded discussion of a single function ends is: "what am I missing"? I've fixed a recurring problem and don't see any further problems which the compiler won't catch and error on. But, I might be too deep in the weeds of one variation to consider the "other" use cases which would lead this to producing yet other common mistakes. I've tried but can't see any real downfalls other than annoying folks used to the more common signatures.
I don't expect a perfect answer, I just don't want to give a poor one if I write an article including this. smile.png
OOPS: Forgot to mention why I was considering the static variation, instead of a helper I was possibly considering using a static "static void Normalize( Vector& )" instead of the void return member. I.e. "Vector::Normalize( toVector )" as an alternative. I think the member+helper works better without the extra typing.
Advertisement

We had a thread about this once, and one thing that someone suggested (which I started doing) was using normalized to represent the non-mutating normalize function.

That is:

v.normalize(); // mutating function, modifies v
v.normalized(); // non-mutating function, does not modify v (returns a new vector)

Another suggestion someone brought up was to use free functions instead of member functions. So then I do something like:

// Personally, I hate overloading operators for cross and dot
// (if my keyboard doesn't have the mathematically right symbol, I write it out)
u = cross(a, b);
v = dot(a, b);
w = normalized(a);

In then end, I combine these two. Free functions are always non-mutating (and in ambiguous cases, the past-tense form of the word is used). Member functions that mutate do not use the past-tense. Member functions that obviously don't mutate are allowed (like magnitude()). Member functions that don't modify the object, but is not obvious, are not allowed (like a normalized() member function). Something like:

a = normalized(b); // b is not modified
b.normalize(); // b is modified (note: I don't define b.normalized() (i.e. a non-mutating member version of normalize))
c = cross(a, b); // a and b are not modified
c = dot(a, b); // a and b are not modified
c = -a; // a is not modified

On top of this, I make the b.normalize() function return void, so it's clear it's meant to be called for its side effects and can't be accidentally used in the same place that the free normalized() function can.

Edit: and for those curious, I'm intentionally not using code tags because they're too frustrating to work with.

[size=2][ I was ninja'd 71 times before I stopped counting a long time ago ] [ f.k.a. MikeTacular ] [ My Blog ] [ SWFer: Gaplessly looped MP3s in your Flash games ]

We had a thread about this once, and one thing that someone suggested (which I started doing) was using normalized to represent the non-mutating normalize function.

Heh, I just linked to the same thread in another thread less than an hour ago.

Hmm..... It is an idea but pretty non-standard from the code bases I've worked with. Not saying it is wrong, just thinking as I type here. In the one case I definitely agree that overloading operators with non-standard meanings is generally a bad idea. Unfortunately I've gotten so damned used to the caret (^) meaning cross product it has become yet another thing I should re-evaluate. smile.png The idea of normalize(d) has me wondering though. Is the semantic change enough to prevent errors and are the signatures hardened against the "oops" cases?

I think the solution I've presented has all the same results but with only two functions? I'm trying to think of other variations and I might be too bogged down since I've given this a fair amount of thought today. smile.png

* edit

A bit more thought after reading the other thread. I still think that "Normalize" is a good name for both variations. The reasons for mutable and const are all valid and how to deal with extra mutated results. I believe my proposal answers all the suggested items though and the only real thing is if the helper should be called "Normalized" instead of just "Normalize"? It is nice to know I'm not being too pedantic in this, seems other folks care also. smile.png

We had a thread about this once, and one thing that someone suggested (which I started doing) was using normalized to represent the non-mutating normalize function.

Heh, I just linked to the same thread in another thread less than an hour ago.

Ha that was a good thread, but I was actually thinking of this thread (and Sneftel's response). I can't remember which thread someone mentioned the free-functions in, though...

[size=2][ I was ninja'd 71 times before I stopped counting a long time ago ] [ f.k.a. MikeTacular ] [ My Blog ] [ SWFer: Gaplessly looped MP3s in your Flash games ]

We had a thread about this once, and one thing that someone suggested (which I started doing) was using normalized to represent the non-mutating normalize function.

Heh, I just linked to the same thread in another thread less than an hour ago.

Yeah, the second thread is the one that got me thinking about it enough to post the original question/description. :)

Thanks for the feedback folks. I'm not sure how much I'll follow the other threads of thought, well only in the case of using two names, but pretty sure the results all basically end up at the same place. The case of the operators, that's going to be difficult, I am just SOOOOO used to the caret meaning cross product it would take a while to break that habit. But, it would be possible to move such an operator to a "horrible_helper.hpp" where it defines such things as external helper operators. That portion I can deal with on my own, making sure I'm not trolling down a crap path, very much appreciate the feedback that I'm not insane by worrying about such trivial items. smile.png

Using a method when you actually want a function that returns a transformed value seems unintuitive. At first it looks like its modifying the object and then as a byproduct returning it again.

Using a method when you actually want a function that returns a transformed value seems unintuitive. At first it looks like its modifying the object and then as a byproduct returning it again.

Yup. But I believe I have the outline written the way you prefer unless I made a goof somewhere. The "member" is the mutator with a void return and the friend/helper is the take something and mutate it returning the result version... In fact I'm almost positive I wrote it correctly because the non-member doesn't even have to be a friend function to do it's job. I could be wrong, pretty sure I'm not though. :)

Its just, are you sure you need the same functionality more than once? Why not just have a function and assign the return value?

This topic is closed to new replies.

Advertisement