1
\$\begingroup\$

I'm creating a tool to analyze a card game which is played by three players. A Card is associated with a number of points and looks like this:

public enum Card 
{
    FIRST_CARD(1),
    SECOND_CARD(2);

    private final int points;
    
    private Card(final int points) 
    {
        this.points = points;
    }
    
    public int getPoints() 
    {
        return points;
    }
}

A game consists of multiple turns. Each turn each of the three players successivelys plays one card. These three cards go into something called a trick.

My question is how to model the trick in the best way.

Tricks are stored in a List<Trick>. The tool will take this List of Tricks of an incomplete game (for example, 3 complete tricks and one trick for which only the first card has been played) and tries to find the optimal remaining plays for all of the three players; i.e. it will complete the List<Trick> such that for all players the amount of points will be maxized. To be able to do that, it's necessary to have access to the three Cards of the Trick, to model incomplete Tricks, to add Cards to a Trick and to get the points associated with a Trick.

I wanted to go for an immutable data approach and came up with two options. The first option uses a regular class to model the different states a trick can be in (one, two or three cards). The second option combines sealed interfaces and multiple records; Brian Goetz calls this combination of sealed types and records algebraic data types in this article. This article also inspired me to try this second option.

First option

public final class Trick
{
    private final Card firstCard;
    private final Card secondCard;
    private final Card thirdCard;

    public Trick(final Card firstCard, final Card secondCard, final Card thirdCard) 
    {       
        this.firstCard = firstCard;
        this.secondCard = secondCard;
        this.thirdCard = thirdCard;
    }

    public Trick(final Card firstCard, final Card secondCard) 
    {
        this.firstCard = firstCard;
        this.secondCard = secondCard;
        this.thirdCard = null;
    }
    
    public Trick(final Card firstCard) 
    {       
        this.firstCard = firstCard;
        this.secondCard = null;
        this.thirdCard = null;
    }
    
    public Trick withSecondCard(final Card secondCard)
    {
        return new Trick(firstCard, secondCard);
    }
    
    public Trick withThirdCard(final Card thirdCard)
    {
        return new Trick(firstCard, secondCard, thirdCard);
    }

    public Card getFirstCard() 
    {
        return firstCard;
    }

    public Optional<Card> getSecondCard() 
    {
        return Optional.of(secondCard);
    }

    public Optional<Card> getThirdCard() 
    {
        return Optional.of(thirdCard);
    }
    
    public int getPoints()
    {
        var points = firstCard.getPoints();

        if(secondCard!=null) points += secondCard.getPoints();
        if(thirdCard!=null) points += thirdCard.getPoints();
        
        return points;
    }
}

Second option

sealed interface Trick {
    
    public Card firstCard(); // Already implemented because of records' feature
    public int getPoints();   

    record TrickWith1Card(Card firstCard) implements Trick
    { 
        public Trick withSecondCard(Card secondCard) 
        { 
            return new TrickWith2Cards(firstCard, secondCard); 
        }

        @Override
        public int getPoints() 
        {
            return firstCard.getPoints();
        }
    }
    
    record TrickWith2Cards(Card firstCard, Card secondCard) implements Trick
    { 
        public Trick withThirdCard(Card thirdCard) 
        { 
            return new TrickWith3Cards(firstCard, secondCard, thirdCard); 
        }

        @Override
        public int getPoints() 
        {
            return firstCard.getPoints() + secondCard.getPoints();
        }
    }

    record TrickWith3Cards(Card firstCard, Card secondCard, Card thirdCard) implements Trick {

        @Override
        public int getPoints() 
        {
            return firstCard.getPoints() + secondCard.getPoints() + thirdCard.getPoints();
        } 
    } 
}

Which of these two options is better? These things come to my mind:

First option:

  1. Good: Uses only one class
  2. Bad: More boilderplate code (Constructors, methods for accessing the cards)
  3. Bad: Optional isn't that convenient to use for the client which access the cards of a trick

Second option:

  1. Bad: Having a Trick interface and three implementing TrickWith1Card, TrickWith2Cardsand TrickWith3Cards for the states a trick can be in feels a bit odd. However, maybe it only feels like that because algebraic data types are new in Java?
  2. Good: Less boilerplate code
  3. Good: The client doesn't have to use Optionals. He can use pattern matching for switch with records to get the concrete record type and then access the cards

I'm wondering if using the interface approach for this use case is recommended. I'm not entirely happy about that the Trick interface only has the firstCard method. That's only the common denominator; shouldn't an interface describe what it's modelling (a Trick) more exhaustively? What do you think?

Of course, if you have an alternative approach I would be interested in that as well!

PS: I intentionally omitted modelling the players because I don't think it's relevant for the core of question 'one class vs algebraic data types'.

\$\endgroup\$
1
  • \$\begingroup\$ Welcome to Stack Review, I added the specific comparative-review tag to your question. \$\endgroup\$ Commented Jan 9, 2022 at 17:00

1 Answer 1

1
\$\begingroup\$

Firstly I find the Card being an enum strange, but I guess it is still just an interface and all you are interested in is points anyway.

But moving past that, Card is a much better candidate for moddeling with sealed classes. Card has a suit and a rank. Suits are Diamond, Heart, Spade and Club. Similar for ranks. You can then also create AceOfSpades implements Card, TwoOfSpades implements Card, etc. Idk, not much benefit from it as you can have a deck factory that creates appropriate cards, but if you like typing (pun intended) then try it out.

As for the Trick I think having sealed classes overcomplicates it. I wouldn't think of a Trick with one card different from a Trick with two cards if there is no real reason for it (and as far as I understand, there really is no reason for it). Your implementation focuses too much on this three card rule which makes the implementation less generic.

From my understanding a Trick is a "bundle" of n cards. It has a maxSize and you can put cards in it. This understanding leaves out the points calculation -> Trick shouldn't know how to score the cards, this is a job for someone else, leaving us with this:

interface Trick {
  int numberOfCards();
  List<Card> cards();
  void insert(Card card);
  boolean isFull();
}

And implementation:

class TrickImpl implements Trick {
  private List<Card> cards;

  int numberOfCards() {
    return this.cards.size();
  }

  int maxNumberOfCards() {
    return 3;
  }

  List<Card> cards() {
    return List.copyOf(cards)
  }

  void insert(Card card) {
    if(isFull()) {
      throw new RuntimeException("Trick is full"); // you can make a special exception
    }

    cards.add(card);
  }

  boolean isFull() {
    return numberOfCards() = maxNumberOfCards();
  }
}

I have no idea if this is useful for you, but this class can be generalized:

abstract class BaseTrick implements Trick {
  private List<Card> cards;

  protected abstract int maxNumberOfCards();

  int numberOfCards() {
    return this.cards.size();
  }

  List<Card> cards() {
    return List.copyOf(cards)
  }

  void insert(Card card) {
    if(numberOfCards() => maxNumberOfCards()) {
      throw new RuntimeException("Trick is full"); // you can make a special exception
    }

    cards.add(card);
  }

  boolean isFull() {
    return numberOfCards() = maxNumberOfCards();
  }
}

class ThreeCardTrick extends BaseTrick {
  protected int maxNumberOfCards() {
    return 3;
  }

  // you can add additional methods.
}

You can do the score calculations with another class/function. For example:

class TrickPointCalculator {
  public static int calculateTrickPoints(Trick trick) {
    return trick.cards().mapToInt(Card::getPoints).sum();
  }
}

// somewhere else where you need the score
Trick trick = new ThreePointTrick();
calculateTrickPoints(trick);

I think that for what it does the Trick is good enough. What it needs to do is just keep track of the cards.

\$\endgroup\$
4
  • \$\begingroup\$ 1/2 Thank you for your suggestions! Using a TrickPointCalculator is a good idea! For some reason I didn't consider using a list internally for Trick. I think with this approach what I would change is to not use List<Card> cards() but Card getCard(int position) - wouldn't that put the class' focus more on domain functionality and avoid exposing a technical detail? \$\endgroup\$
    – user253872
    Commented Jan 9, 2022 at 20:30
  • \$\begingroup\$ 2/2 What I didn't mention, for this project for me extensibility is not important. Comparing the List and the algebraic data type approach now, these things come to my mind: List approach: More natural design and API for Trick. Algebraic data type approach: The risk of accidentally making a mistake using Trick's API is probably lower compared to the List approach where it's possible to cause an exception in the void insert(Card card) method and while accessing the cards in the List. 2/2 \$\endgroup\$
    – user253872
    Commented Jan 9, 2022 at 20:50
  • \$\begingroup\$ I chose List, because it has a nicer to work with API. Of course you can add getCard(position), but you would then have to make it Optional<Card>. The possibility for making an error are there with the insert, but you at least get the exception. With algebraic types you have to create objects and have to know how many cards the trick has, so you can create the new trick with the proper amount of cards. With insert you just have to wrap it in try-catch and when you catch it, create a new Trick and add it to the other Tricks. \$\endgroup\$
    – Blaž Mrak
    Commented Jan 10, 2022 at 6:37
  • \$\begingroup\$ The exception is good in this case, because it tells you that you are doing something wrong (it notifies you instead of failing silently, but you can always check isFull before inserting if you don't like try-catch). What you can do is add some convenience method insertIfNotFullElse(card, () -> tricks.add(new Trick(card))) instead of basic insert, which basically makes you handle the case when the Trick is full, although I have to admit it feels weird... \$\endgroup\$
    – Blaž Mrak
    Commented Jan 10, 2022 at 6:47

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.