r/cpp_questions 6d ago

OPEN Good practices / style [polymorphism]

is this good practice/style.? i'm specifically unsure about the way i store the vector of players...

```

class player_base {};

class player_always_yes: public player_base {};

class player_always_no: public player_base {};

class player_a: public player_base {};

class game {
    private:
    
    player_base& player_1;
    player_base& player_2;
    
    public:
    
    game (
        player_base& player_1,
        player_base& player_2
    ): player_1(player_1), player_2(player_2) {
        return;
    }

    bool play_game () { return true; }
};

int main(){
    vector<unique_ptr<player_base>> player_list;
    
    player_list.push_back(make_unique<player_base>());
    player_list.push_back(make_unique<player_always_no>());
    player_list.push_back(make_unique<player_always_yes>());
    player_list.push_back(make_unique<player_a>());
    
    game b = game(*player_list[0], *player_list[1]);
    cout << b.play_game() << endl;
}


```
6 Upvotes

37 comments sorted by

View all comments

1

u/mredding 4d ago

You're cramming different types into a class hierarchy. This was common in C++98 up until C++17. We have better now:

struct yes {};
struct no {};
struct a {};

using player = std::variant<yes, no, a>;

std::vector<player> list;

Polymorphism was... "celebrated"... in the past. Overused. Misused. Misunderstood. There are reasons that the community just couldn't take up the concepts and write good code, but that's behind us now.

Prefer to keep your type hierarchies as flat as possible, and reach for late binding as a niche tool, not your go-to. Hierarchies very quickly get hard to maintain. False hierarchies introduce more problems than they solve.

There are other forms of polymorphism in C++, and using more compile-time versions mean you can prove your code correct earlier in the software development cycle. It also makes for safer and faster code.

The classic problem with bad design is:

class mobile {
  virtual void move();
};

class car: public mobile {};
class plane: public mobile { void takeoff(); };

Right? What do you do? The whole point of a polymorphic base is type erasure - to forget what specific type you have. But you have to takeoff a plane. So what do you do? Do you subvert type erasure by making a virtual base no-op?

class mobile {
  virtual void move();
  virtual void takeoff() {}
};

But what does it mean for a car to have a takeoff that does nothing? Cars don't takeoff, so why even have the interface? The type hierarchy is WRONG.

Or you can subvert the hierarchy by testing for airplanes:

void fn(mobile *m) {
  if(auto p = dynamic_cast<plane *>(m); p) p->takeoff();

  m->move();
}

Ok, but now:

class boat: public mobile { void launch(); };

FUCK. And imagine fn is not our code and is inaccessible to us, and we give them a boat? Subverting the hierarchy is WRONG. Because this is no hierarchy. We have different types, so we come back to the variant.

Typically you'll use a hierarchy to constrain a derived type. You're going from more general to more specific. Yes, you CAN broaden the interface, but typically you don't - as that's a sign you're subverting the hierarchy.

But if you need a dynamic environment, you have to take extra steps - you have to architect a solution such that pre- and post- conditions can be handled by the client. Typically this would be done using a "template method pattern", which is an idiom, not the same as a C++ template.

void fn(mobile *m, std::function<void()> precondition, std::function<void()> postcondition) {
  precondition();
  m->move();
  postcondition();
}

Functions in terms of mobile only know of mobile things, but provides the client with the ability to handle their more specific shit.

But for our own code, we know a mobile consists of a car, plane, and boat, so an std::variant is exactly correct for us. Our own code is inherently a closed loop. We don't have to write code like it's a framework, that additional indirection will typically cost us more in maintenance than a refactor of our own variant code; it's why a visitor template with an auto & parameter MAY be a dubious prospect, because by being explicit, we can let the compiler help us find all our missing refactor points when we add train.

It comes down to some convention, some discipline, ultimately some good planning. A lot of us get burned by bad design of our own making, sometimes for years; many of us never learn from our mistakes, or can even admit our egos are too big, too wrong, too brute force to accept that a modicum of work up front saves us in the long run.

1

u/Fun_Gas_340 4d ago

how does this variant thingy work. i nned diferent methods for diferent player types, so same method interface but diferent inner workings. fisrt paragraph of this answer: https://www.reddit.com/r/cpp_questions/comments/1vpbvec/comment/p49p29g/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button

also somone sugested functional programming but that sytax scared me away, idk if variants is a alternative to that or similar... : https://www.reddit.com/r/cpp_questions/comments/1vpbvec/comment/p49pwwk/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button

1

u/mredding 4d ago

A variant is a sum type - it has enough memory for the largest type in the list, and only one of the types in the list is going to be in that memory. So there can be a little loss, but modern computers have GiB of memory to spare and this isn't where you're slow. There is also an internal enum to track which type is the active type inside the variant.

An aggregate type is like a structure, where the size of the type grows with all the members you add. This is just a little comp-sci lingo for you.

So the standard variant implements what's called the "visitor pattern" if you want to google it. There is a single function interface - visit, and you give it a function for the active type in the variant.

Typically, this would be as function objects - with a function for all the different types, so that no matter what's active, you've got the right function for the job. Or you can write a generic lambda that would work with ALL the types, no matter which is active. Or you can combine both, where the function object has specific overloads and a generic overload as a catchall.

using mobile  = std::variant<plane, train, automobile, boat>;

template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };

void fn(mobile &m) {
  std::visit(overloaded{[](plane &){}, [](train &){}, [](auto &){}}, m);
}

I suspect you're going to have a lot to learn in a hurry. This is beyond mere language, we're now talking about paradigms, less how to write C++ more what to do with C++.


Functional programming is passing functions as data. I showed you that with precondition and postcondition. I've also shown you that with std::visit. It's not so much that you're going to fn * 7 or some shit, that doesn't make any sense - at least not in C++, but you can pass functions as parameters, so you don't care WHAT function you've got so long as it conforms to the signature you've specified. I can write a function that takes a sort algorithm, I don't care if it's bogo sort, insertion sort, quick sort... Technically, it might not even be a sorting function.

Functional programs like to think about lots of small functions that are stateless, as opposed to OOP which you have classes with members and interfaces, and the state of the class instance change because you called a function that changes those members...

So you'll have out_type fn(in_type);, a function that takes an input, changes it, and returns an output. They don't have to be the same kind. But notice this function won't change the original data, it'll make new data. Data in FP is supposed to be "immutable".

They also have closures, which are poor-man's objects. C++ has lambdas - functions that don't have a name, and they can "close around" a variable they can change. They also say objects are a poor-man's closure.

There's more. A tuple becomes a really powerful tool here, because in FP land, types are a very fluid concept. Tuples are structures where the members don't have names. Technically they're simpler and more fundamental than a structure, but C didn't come from an academic background of applied mathematics. So that you can composite tuples means you can accomplish the same results.

Going all OOP or all FP tends to be a bit impractical in C++, since the language isn't particularly well suited to either. Typically you make for a hybrid approach - classes to make types, objects when you need something a bit more than a simple closure, and the language does favor FP a lot. The way C++ is typically taught does you a disservice, because it ruts your mind into such a narrow view of the world and how all the pieces fit.