0

I'm currently building a blackjack game, and one of my classes is called "Deck". This deck needs to do two things:

  1. Return a set of cards that other objects can use (this is equivalent to dealing those cards out)
  2. Remove those cards from the deck (once a card has been dealt, it should be removed).

At the moment, I've implemented these two pieces of functionality by creating two methods: selectCards() and removeIndexFromDeck. I first call selectCards() which returns the selected cards, but also calls removeIndexFromDeck().

Does this approach violate best practices? It looks like my selectCards() function has both a return value and side-effects.

If it does violate best practices, how would I change these methods, but make sure I'm still able to return the selected cards and also remove them from the deck.

Thanks!

class Deck {
  constructor() {
    this.deck = [];
    ['♦', '♣', '♥', '♠'].forEach(suit => {
      ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'].forEach(value => {
        this.deck.push(`${value}${suit}`);
      });
    });
  }

  selectCards(numCards) {
    let selectedCards = [];

    while (numCards > 0) {
      let randIndex = Math.floor(Math.random() * this.deck.length);
      selectedCards.push(this.deck[randIndex]);
      this.removeIndexFromDeck(randIndex);
      numCards -= 1;
    }

    return selectedCards;
  }

  removeIndexFromDeck(index) {
    this.deck.splice(index, 1);
  }
}
bugsyb
  • 5,662
  • 7
  • 31
  • 47
  • "*Does this approach violate best practices?*" ¯\\_(ツ)_/¯ what are you trying to model? An actual *physical deck* that people interact with? Your removed cards don't seem to go anywhere. With a physical deck, they'd go back eventually. Are you just trying to model more abstract deck interactions? You can just mark cards as gone or otherwise not try to treat them as if you literally "pull them out". – VLAZ May 08 '21 at 18:23
  • `select` sounds like it somehow marks them as selected. I'd name the method `removeRandomCards`. But otherwise, yes it's fine to have methods with a return value and side effects. The [CQS](https://en.wikipedia.org/wiki/Command%E2%80%93query_separation) does not apply to everything. – Bergi May 08 '21 at 18:29

2 Answers2

2

It is true that you should generally avoid to both mutate and return a value, but there are always exceptions to good rules. Even native Java Script has exceptions to this rule, and Array#pop is probably the most well known one. Yet, many will agree that pop is very useful as it currently works. Also Array#splice, which you call in your script, mutates and returns information.

Unless you want to abandon OOP and move to functional programming, this pattern is fine. I would just make sure that the name of the method leaves as little doubt as possible about this double effect. For that reason I would call selectCards rather extractCards or pullCards. This gives a stronger hint that the deck is mutated.

I would also suggest implementing a shuffle method, instead of using random indexes at the moment of selecting cards. If you have support for private properties, then define the deck array as private, so to hide the shuffled contents from the outside world.

Here is what I mean (without private):

class Deck {
  constructor() {
    this.deck = Array.from('♦♣♥♠', suit =>
      ['A','2','3','4','5','6','7','8','9','10','J','Q','K'].map(
        value => `${value}${suit}`
      )
    ).flat();
  }

  shuffle() { // mutates the deck, much like Array#sort mutates an array
    let deck = this.deck;
    for (let i = deck.length - 1; i > 0; i--) {
      let j = Math.floor(Math.random() * (i + 1));
      let temp = deck[i];
      deck[i] = deck[j];
      deck[j] = temp;
    }
  }

  extractCards(numCards) {  // Better name. Mutates & returns. 
    // Perform a controlled Array#splice
    if (typeof numCards !== "number" || numCards <= 0) throw "Invalid argument";
    if (this.deck.length < numCards) throw "Deck does not have enough cards for this operation";
    return this.deck.splice(-numCards);
  }
}

let deck = new Deck();
deck.shuffle();
console.log(...deck.extractCards(4));
trincot
  • 317,000
  • 35
  • 244
  • 286
  • Got it. And in that case, does it even make sense to have the separate removeIndexFromDeck() method? Or is it fine to just have the mutation happen in the selectCards() method. – bugsyb May 08 '21 at 18:30
  • 1
    That depends on whether you need `removeIndexFromDeck` in other circumstances as well. If you really have no other use for it, you could indeed just do that inside the selectCards method. If you keep it, I would not repeat `Deck` in its name, as that is clear from the class. You could call it `removeCardAt` maybe. – trincot May 08 '21 at 18:32
  • `this.removeIndexFromDeck(randIndex);` is generally bad practice, it's a code smell and could be changing the data or entire class unexpectedly. It has no return value and it's pulling the rug out from anyone else who is using `this.deck`. I don't think `pop()` is an example of an "exception to the rule", there's plenty of great ways to manipulate arrays in an immutable style. – Andy Ray May 08 '21 at 18:50
  • @AndyRay, opinions will always differ on this topic. That's why we have *functional programming* as an alternative. I disagree however that code that is not based on the immutable paradigm is by consequence ***"generally"*** bad practice. It is maybe bad practice in a certain school of thought. But I would not go further than that. As you also know, the immutable way of working has its own downsides. – trincot May 08 '21 at 18:56
  • It's pretty spooky to me that if you switch the order of the lines `selectedCards.push` and `this. removeIndexFromDeck` the program will fail. There's an implicit/magic ordering there you can't see from looking at the method on its own. Or if you choose to pull `this.deck.length` out of the loop into a variable it will fail, and it might not be obvious why, because data is getting manipulated behind the scenes. It's generally bad practice to program this way. I also agree this question should be closed as an opinion question. – Andy Ray May 08 '21 at 19:04
0

If you're using classes, that usually implies there are side effects, since the data is part of the class. Aka you get methods that don't have return values (often meaning they have side effects) that mutate class state in place. The closest thing you can do is make as many methods as you can return new deck values, instead of using mutate/side effect array methods, and then in your parent method do this.deck = removeIndexFromDeck(index) to limit where you have side effects.

If you abandon classes and instead make functions that manipulate data, and treat deck as only data and not as a smart class, the functions themselves won't be able to have side effects.

You could also use the pattern of returning a new Deck class instance when you make a mutation, which means the data in Deck will always be immutable. I don't see a benefit of this in this case though.

Andy Ray
  • 30,372
  • 14
  • 101
  • 138
  • In that case, if we're saying that it's pretty common to mutate values when using classes, is there even a point to having the removeFromIndex() method? Or can I just include that as part of the selectCards() method? – bugsyb May 08 '21 at 18:26
  • To encapsulate your data layer, I think it's fine to have a function that's `const removeFromDeck = (deck, index) => {...}` so that someone using a deck doesn't need to know how the deck is manipulated. In the implementation, I would also have a bunch of array/object/collection utility functions you can use wherever - like removeFromArray, that return immutable copies of the input data. This is generally called a "data abstraction" https://www.ocf.berkeley.edu/~shidi/cs61a/wiki/Data_abstraction#:~:text=Constructors%20create%20an%20object%2C%20bundling,of%20information%20from%20the%20object. – Andy Ray May 08 '21 at 18:44