3
\$\begingroup\$

I have seen many posts regarding a standard 52-card deck of playing cards. I recently was composing an answer to one such post when I realized my answer should be its own post since (A) the answer diverged greatly from the OP and (B) my classes addressed my own objectives and not the OP's.

I have no specific questions other than the implied at CR: please review and critique the code. My particular interest is in the API, but I also welcome comments about the Console UI example. I am using C# and .NET 5.

enter image description here

Here are my objectives:

The base objects of Rank, Suit, Card, and Deck will exist in a re-useable API which can be used by any UI application, be it Console-based, WPF, WinForms, Unity, etc.

  • I want to generate a standard 52-deck of cards.

  • I also want the versatility to generate many other types of decks from that same API. Maybe I want a deck that includes 1 or more Jokers. Maybe I want a non-standard deck composed of 2 suits of 10 cards each. Maybe I want a casino style super deck composed of 6 standard decks.

  • I want to completely avoid the magic numbers of 13, 4, and 52. I want to go as far as avoiding them as constants.

  • While many use only an enum for rank or suit, I want a little more meta data associated with each rank and suit. For example, the color is the suit is nice to know in an API.

About the API

My Rank struct does have a superficial FaceValue property but that should not be confused with the value or points of a given card under the rules of a particular game. Consider the treatment of various cards for various games:

  • In Poker, an Ace may be a 1 or a 14.
  • In Blackjack, an Ace may be a 1 or 11.
  • In Freecell, an Ace is a 1.
  • In Hearts, an Ace has a value of 14, but the Ace of Hearts is equal to 1 point.
  • In Crazy Eights, only the facevalue of the Ace matters.

A Deck is not a Game. For this post, a Game is not defined. A Deck is oblivous to the notion that in some games one suit may trump another suit, or that a given rank may be of greater value or points that another rank. Those concerns would be handled by a given game object and its own set of rules and are (rightfully) ignored here.

There are a couple of methods in Deck that I chose to be Fluent. One in particular is Shuffle, which began as a void but required 2 lines such as:

deck.Shuffle();
deck.WriteLines();

With a Fluent design, I can do that in one line with:

deck.Shuffle().WriteLines();

Rank Structure

public enum RankName : byte
{
    Ace = 1,
    Two,
    Three,
    Four,
    Five,
    Six,
    Seven,
    Eight,
    Nine,
    Ten,
    Jack,
    Queen,
    King,
    Joker = byte.MaxValue
}

public struct Rank : IEquatable<Rank>
{
    private Rank(RankName name, string symbol)
    {
        this.Name = name;
        this.Symbol = symbol;
    }

    public RankName Name { get; }
    public string Symbol { get; }

    // Do not confuse the FaceValue with a card's true Value or rank in a Game.
    // A Game object woud define its own rules and assign values accordingly.
    // Examples:
    //      Poker - Ace's Value may be a 14 or a 1.
    //      Blackjack - Ace's Value may be 10 or 11.
    //      FreeCell - Ace's Value is a 1.
    // A deck of playing cards is oblivous to values assigned by a Game,
    // but a deck can know about the FaceValue of each card.
    public byte FaceValue => (byte)Name;

    public bool Equals(Rank other) => Name.Equals(other.Name);

    public override string ToString() => Symbol;

    public static Rank Ace   => new Rank(RankName.Ace,   "A");
    public static Rank Two   => new Rank(RankName.Two,   "2");
    public static Rank Three => new Rank(RankName.Three, "3");
    public static Rank Four  => new Rank(RankName.Four,  "4");
    public static Rank Five  => new Rank(RankName.Five,  "5");
    public static Rank Six   => new Rank(RankName.Six,   "6");
    public static Rank Seven => new Rank(RankName.Seven, "7");
    public static Rank Eight => new Rank(RankName.Eight, "8");
    public static Rank Nine  => new Rank(RankName.Nine,  "9");
    public static Rank Ten   => new Rank(RankName.Ten,   "10");  // Alert: 2-character symbol
    public static Rank Jack  => new Rank(RankName.Jack,  "J");
    public static Rank Queen => new Rank(RankName.Queen, "Q");
    public static Rank King  => new Rank(RankName.King,  "K");

    // Other possible Joker symbols:
    //      "WC" for Wildcard
    //      "<Ŵ>" which is a Wild way of using W for Wildcard
    public static Rank Joker => new Rank(RankName.Joker, "¡J!"); // VERY special symbol also > 1 character

    public static IList<Rank> StandardRanks => new Rank[] { Ace, Two, Three, Four, Five, Six, Seven, Eight, Nine, Ten, Jack, Queen, King };
}

Suit Structure

public enum SuitColor : byte { None, Black, Red }

public enum SuitName : byte { None, Clubs, Diamonds, Hearts, Spades }

public struct Suit : IEquatable<Suit>
{
    private Suit(SuitName name, string symbol, SuitColor color)
    {
        this.Name = name;
        this.Symbol = symbol;
        this.Color = color;
    }

    public SuitName Name { get; }
    public SuitColor Color { get; }
    public string Symbol { get; }

    public bool Equals(Suit other) => Name.Equals(other.Name);

    public override string ToString() => Symbol;

    public static Suit Clubs    => new Suit(SuitName.Clubs,    "♣", SuitColor.Black);
    public static Suit Diamonds => new Suit(SuitName.Diamonds, "♦", SuitColor.Red);
    public static Suit Hearts   => new Suit(SuitName.Hearts,   "♥", SuitColor.Red);
    public static Suit Spades   => new Suit(SuitName.Spades,   "♠", SuitColor.Black);
    public static Suit None     => new Suit(SuitName.None,      "", SuitColor.None);

    public static IList<Suit> StandardSuits => new Suit[] { Hearts, Clubs, Diamonds, Spades };
    public static IList<Suit> RedSuits => new Suit[] { Diamonds, Hearts };
    public static IList<Suit> BlackSuits => new Suit[] { Clubs, Spades };
}

Card Structure

public struct Card : IEquatable<Card>
 {
    public Card(Rank rank, Suit suit)
    {
        this.Rank = rank;
        this.Suit = suit;
    }

    public Rank Rank { get; }
    public Suit Suit { get; }

    public bool Equals(Card other) => Rank.Equals(other.Rank) && Suit.Equals(other.Suit);

    public override string ToString() => $"{Rank}{Suit}";
}

Deck Class

public class Deck
{
    public Deck(IEnumerable<Suit> suits, IEnumerable<Rank> ranks, int numberOfDecks = 1, int numberOfJokers = 0)
    {
        Suits = suits.ToArray();
        Ranks = ranks.ToArray();
        DeckCount = numberOfDecks;
        JokerCount = numberOfJokers;

        _cards = new Card[(Suits.Count * Ranks.Count * numberOfDecks) + numberOfJokers];
        ResetToNewDeck();
    }

    private Card[] _cards { get; }
    public IReadOnlyList<Card> Cards => _cards;
    public IReadOnlyList<Suit> Suits { get; }
    public IReadOnlyList<Rank> Ranks { get; }
    public int DeckCount { get; }
    public int JokerCount { get; }

    private Random _random = new Random();

    public static Deck CreateStandardDeck() => new Deck(Suit.StandardSuits, Rank.StandardRanks, numberOfDecks: 1, numberOfJokers: 0);

    public Deck ResetToNewDeck() 
    {
        int i = 0;

        for (var d = 1; d <= DeckCount; d++)
        foreach (var suit in Suits)
        foreach (var rank in Ranks)
        {
            _cards[i++] = new Card(rank, suit);
        }

        for (var j = 0; j < JokerCount; j++)
        {
            _cards[i++] = new Card(Rank.Joker, Suit.None);
        }

        return this; // trying to be a little Fluent
    }

    public Deck Shuffle()
    {
        // Fisher-Yates shuffle: https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle
        for (var i = 0; i < _cards.Length - 1; i++)
        {
            var j = _random.Next(i, _cards.Length);
            if (i != j)
            {
                var temp = _cards[i];
                _cards[i] = _cards[j];
                _cards[j] = temp;
            }
        }
        return this; // trying to be a little Fluent
    }
}

A sample Console UI

I wanted a little razzle dazzle when displaying the cards, so I choose to colorize them with a white background and red or black text. I made an concession regarding the Joker to make it stand-out more and not be confused for a "J" or Jack.

ConsoleExtensions class

// These methods do not extend the Console object, but rather use the Console as the primary UI.
public static class ConsoleExtensions
{
    // Tuple of ConsoleColor pair is for Foreground and Background respectively.
    private static IDictionary<SuitColor, (ConsoleColor, ConsoleColor)> ColorMap =>
        new Dictionary<SuitColor, (ConsoleColor, ConsoleColor)>()
        {
            { SuitColor.Black, (ConsoleColor.Black,   ConsoleColor.White) },
            { SuitColor.Red,   (ConsoleColor.DarkRed, ConsoleColor.White) },
            { SuitColor.None,  (ConsoleColor.White,   ConsoleColor.DarkYellow) }
        };

    private const string DefaultCardSeparator = " ";
    private const string DefaultLinePrefix    = "   ";

    public static void Write(this Card card)
    {
        var (foreColor, backColor) = ColorMap[card.Suit.Color];
        Console.ForegroundColor = foreColor;
        Console.BackgroundColor = backColor;
        Console.Write(card);
        // Why ResetColor?  For other methods that write many cards and use a separator
        // between each card.  That separator should use the default Console colors
        // so that is cannot be visually mistaken as part of a card.
        Console.ResetColor();
    }

    private static HashSet<string> NewLineSet => new HashSet<string>() { "\n\r", "\r\n", "\n" };

    public static void WriteLine(this IEnumerable<Card> cards, string separator = DefaultCardSeparator, string linePrefix = DefaultLinePrefix)
    {
        separator ??= "";
        linePrefix ??= "";
        var isNewLine = NewLineSet.Contains(separator);
        var repeatPrefix = true;
        foreach (var card in cards)
        {
            if (repeatPrefix)
            {
                Console.Write(linePrefix);
                repeatPrefix = isNewLine;
            }
            card.Write();
            Console.Write(separator);
        }
        if (!isNewLine)
        {
            Console.Write(Environment.NewLine);
        }
    }

    public static void WriteLine(this Deck deck, string separator = DefaultCardSeparator, string linePrefix = DefaultLinePrefix) => deck.Cards.WriteLine(separator, linePrefix);

    public static void WriteLines(this Deck deck, int maxPerRow = 0, string separator = DefaultCardSeparator, string linePrefix = DefaultLinePrefix)
    {
        if (maxPerRow <= 0)
        {
            maxPerRow = deck.Ranks.Count;
        }
        var rows = (int)Math.Ceiling(deck.Cards.Count / (double)maxPerRow);
        for (var i = 0; i < rows; i++)
        {
            deck.Cards.Skip(i * maxPerRow).Take(maxPerRow).WriteLine(separator, linePrefix);
        }
    }
}

The Main Program

class Program
{
    static void Main(string[] args)
    {
        var deck = Deck.CreateStandardDeck();

        Console.WriteLine("Fresh new standard deck:");
        deck.WriteLines();

        Console.WriteLine($"{Environment.NewLine}Shuffled deck:");
        deck.Shuffle().WriteLines();

        Console.WriteLine($"{Environment.NewLine}New deck with 2 Jokers:");
        deck = new Deck(Suit.StandardSuits, Rank.StandardRanks, numberOfDecks: 1, numberOfJokers: 2);
        deck.WriteLines();

        Console.WriteLine($"{Environment.NewLine}Shuffled deck with 2 Jokers:");
        deck.Shuffle().WriteLines();

        Console.WriteLine($"{Environment.NewLine}Fresh deck with each card on it's own line.");
        // Example (1)
        deck.ResetToNewDeck().WriteLine(Environment.NewLine);
        //  Example(2)
        //deck.ResetToNewDeck().WriteLines(maxPerRow: 1, separator: null);

        // To create a special deck of 2 suits and 10 ranks, use:
        Console.WriteLine($"{Environment.NewLine}Special deck of 2 suits and 10 ranks");
        deck = new Deck(new[] { Suit.Spades, Suit.Hearts },
                        new[] { Rank.Ace, Rank.Two, Rank.Three, Rank.Four, Rank.Five, Rank.Six, Rank.Seven, Rank.Eight, Rank.Nine, Rank.Ten });
        deck.WriteLines();

        Console.WriteLine($"{Environment.NewLine}Press ENTER to close.");
        Console.ReadLine();
    }
}

Sample Output Images

enter image description here

enter image description here

How to create a Casino Super Deck

// Fun fact: most Blackjack games in Las Vegas use 6 to 8 decks!
//      https://en.wikipedia.org/wiki/Blackjack
// To create one big deck composed of 6 standard decks, use:
deck = new Deck(Suit.StandardSuits, Rank.StandardRanks, numberOfDecks: 6);
\$\endgroup\$
3
  • \$\begingroup\$ First of all congratulation, the implementation is pretty neat. I will leave a proper review today or tomorrow, but I just want to ask that are you aware of the CA1067 build warnings? \$\endgroup\$ Commented Jun 10, 2021 at 9:28
  • 1
    \$\begingroup\$ @PeterCsala I am not that familiar with CA1067 build warnings. Thus, I welcome your review because I want to learn about such things, and benefit from the varied experiences of fellow developers. I do not post to win points but rather to gain knowledge that makes me better in my job. \$\endgroup\$
    – Rick Davin
    Commented Jun 10, 2021 at 13:37
  • \$\begingroup\$ My Blackjack example includes Deck, Suit, Card, and Shoe classes which you might want to check out. \$\endgroup\$
    – Aron
    Commented Jun 10, 2021 at 21:00

1 Answer 1

1
\$\begingroup\$

In this review I will focus only on the Rank type. I do this because I want to emphasize the importance of immutability.

Const vs Readonly vs Immutable

  • Constant: A mechanism to create an alias for a specific literal
  • Readonly: A structure/mechanism to prevent modification after initialization
  • Immutable: A structure/mechanism to support modification by creating a new copy

So, as you can see a constant is readonly, but not immutable.

I have seen some confusion around these terms that's why I wanted to clarify them. The struct data type has value semantics, which means that during assignment a copy will be created. Which might imply that the structs are immutable.

The thing is you can write code that modifies the state of the struct. You can define properties with setters and you can create methods which can overwrite this (Sample). In order to enforce immutability at compilation time you can use readonly structs.

That means we can only assign values inside the constructor. So you can't assign default value to the property. But with C# 9's init-only properties you can do that and extend this concept even further. So with the init keyword you can make property assignment from constructor, property default value initializer and object creation.

(Please bear in mind that default value initializers are not allowed in structs for instance level properties.)

public readonly struct Rank
{
    public Rank(RankName name, string symbol)
    {
        Name = name;
        Symbol = symbol;
    }

    public RankName Name { get; init; }
    public string Symbol { get; init; }
}

var r1 = new Rank(RankName.Ace, "A");
Rank r2 = new (RankName.Ace, "A");
Rank r3 = new() { Name = RankName.Ace, Symbol = "A" };

With r3 as you can see we are using a parameterless default constructor. The thing is that this ctor is always present. Which means that even though you have defined your parameterized ctor as private you can create a Rank instance anywhere. If you want to avoid this then you have to switch from struct to class

Expression-bodied accessor vs Default value initializer

public static Rank Ace => new(RankName.Ace, "A");
public static Rank Ace { get; } = new(RankName.Ace, "A");
  • In the former case you have defined a getter which will always return a new Rank instance.
  • In the latter case you have defined a getter which will always return the same Rank instance.

As I said before default value intializer can't be used for instance level properties inside struct. But this limitation is not applicable for static properties.

static getter only property vs static readonly field

If you want to separate Rank type definition from its predefined instances then it might make sense to create a static Ranks class where you define the well-known constants:

public static class Ranks
{
    public static readonly Rank Ace = new(RankName.Ace, "A");
    public static readonly Rank Two = new(RankName.Two, "2");
    public static readonly Rank Three = new(RankName.Three, "3");
    public static readonly Rank Four = new(RankName.Four, "4");
    public static readonly Rank Five = new(RankName.Five, "5");
    public static readonly Rank Six = new(RankName.Six, "6");
    public static readonly Rank Seven = new(RankName.Seven, "7");
    public static readonly Rank Eight = new(RankName.Eight, "8");
    public static readonly Rank Nine = new(RankName.Nine, "9");
    public static readonly Rank Ten = new(RankName.Ten, "10");
    public static readonly Rank Jack = new(RankName.Jack, "J");
    public static readonly Rank Queen = new(RankName.Queen, "Q");
    public static readonly Rank King = new(RankName.King, "K");
    public static readonly Rank Joker = new(RankName.Joker, "¡J!");
}

Manually vs Programmatically listing members

With this class in our hand we can use reflection and Lazy to construct the StandardRanks in a programatic way. This would prevent such situation when you define a new Rank and forgot to add it to the StandardRanks. (I know, I know, this might seem a bit overkill, but there are situations where this technique is really useful.)

public static readonly ImmutableArray<Rank> StandardRanks = _ranks.Value;

private static Lazy<ImmutableArray<Rank>> _ranks
    = new(() => GetAllRanks().Where(r => r.Name != RankName.Joker).ToImmutableArray(), true);

private static IEnumerable<Rank> GetAllRanks()
    => typeof(Ranks)
        .GetFields(BindingFlags.Public | BindingFlags.Static)
        .Where(f => f.IsInitOnly && f.FieldType == typeof(Rank))
        .Select(f => (Rank)f.GetValue(null));

IList vs IReadOnlyCollection vs ImmutableArray

Here I've used ImmutableArray instead of IList or IReadOnlyCollection.

In case of IList you can easily do this:

Ranks.StandardRanks.Remove(Ranks.Ace);
Ranks.StandardRanks.Add(Ranks.Joker);

Which is not good, because you don't want to allow mutation on this collection ifself.

With IReadOnlyCollection you can prevent the removal or extension of the collection, but you can't prevent the modification of the items:

var ranks = (Rank[])Ranks.StandardRanks;
ranks[0] = Ranks.Joker;

You can always back cast it to the original data structure and modify its items.

If you use ImmutableArray you still have Add and Remove methods but they will create a new ImmutableArray rather than modifying the existing one.

\$\endgroup\$
1
  • 1
    \$\begingroup\$ Thank you for the review. I mostly code with C# 7 and .NET Framework 4.6.2 due to a critical 3rd party library for work. It's nice to learn new things when I dabble with .NET 5 and C# 8. \$\endgroup\$
    – Rick Davin
    Commented Jun 14, 2021 at 11:18

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.