DEV Community

Mateus Cechetto
Mateus Cechetto

Posted on

Handling Evolving Requirements: Leveraging C# Params for Variable Number of Parameters

When building software, we need to be aware that the requirements will change over time. Knowing that, we need to write code that is easy to maintain and to modify. Recently, in my project we had code that looked like this:


public interface ICardWithHighlight : ICard
{
    bool ShouldHighlight(Card card);
}
Enter fullscreen mode Exit fullscreen mode

Pretty simple and straightforward code. The cards that implemented that interface would have to set a condition if they should highlight or not other cards. Then a new requirement arrived: cards could now be highlighted in Green or Yellow, depending on different conditions. This forced us to change the interface, because only telling if they should highlight was not enough.


public interface ICardWithHighlight : ICard
{
    HighlightColor ShouldHighlight(Card card);
}

public enum HighlightColor
{
    None,
    Green,
    Yellow,
}

public static class HighlightColorHelper
{
    public static HighlightColor GetHighlightColor(bool condition)
    {
        return condition ? HighlightColor.Green : HighlightColor.None;
    }

    public static HighlightColor GetHighlightColor(bool condition, bool secondCondition)
    {
        if(condition) return HighlightColor.Green;
        if(secondCondition) return HighlightColor.Yellow;
        return HighlightColor.None;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, our code can highlight cards with one condition in green, and we use method overloading to handle two conditions as well. However, it's evident that this solution isn't the best. What if we needed to highlight with another color for a third condition, and a fourth, fifth... With the current solution, we would have to create new methods for each color, and all of those methods would look very similar.

This is where C# parameter arrays can help us. By using the params keyword, we can make our GetHighlightColor() method accept a variable number of parameters of the specified type. If you pass a multiple values, C# will automatically convert them into an array of that type.

With it, we can update the GetHighlightColor() without breaking compatibility with the existing code (when we changed ICardWithHighlight to make ShouldHighlight() return a color, we had to update our entire codebase). This approach improves flexibility and maintainability, making it easier to add new colors.

public enum HighlightColor
{
    None,
    Green,
    Yellow,
    Red,
    Blue,
    Pink,
}

public static class HighlightColorHelper
{
    private static readonly Dictionary<int, HighlightColor> _colorMapping = new()
    {
        { 0, HighlightColor.Green },
        { 1, HighlightColor.Yellow },
        { 2, HighlightColor.Red },
        { 3, HighlightColor.Blue },
        { 4, HighlightColor.Pink }
    };

    public static HighlightColor GetHighlightColor(params bool[] conditions)
    {
        if (conditions.Length == 0)
            return HighlightColor.None;

        for (var i = 0; i < conditions.Length; i++)
        {
            if(!conditions[i]) continue;
            if (_colorMapping.TryGetValue(i, out var color))
            {
                return color;
            }
        }

        return HighlightColor.None;
    }
}

Enter fullscreen mode Exit fullscreen mode

How it works:

  • params bool[] conditions: The params keyword allows the method to accept any number of boolean conditions. The conditions are passed as an array, which gives us the flexibility to pass as many conditions as needed.
  • Dictionary Mapping: We use a dictionary _colorMapping to map each condition index to a specific color. This approach avoids using hard-coded indices or a long switch statement. Adding a new color simply requires adding a new entry to the dictionary.
  • Prioritized Matching: The method processes each condition in the order in which it is passed, returning the color for the first condition that is true. If no conditions are met, it returns HighlightColor.None.

Example Usage

// Highlights 1 cost minion
public class TrustyFishingRod : ICardWithHighlight
{
    public string GetCardId() => HearthDb.CardIds.Collectible.Hunter.TrustyFishingRod;

    public HighlightColor ShouldHighlight(Card card) =>
        HighlightColorHelper.GetHighlightColor(card is { Type: CardType.MINION, Cost: 1 });
}

// Highlights elementals in one color and beasts in other
public class Thunderbringer : ICardWithHighlight
{
    public string GetCardId() => HearthDb.CardIds.Collectible.Neutral.Thunderbringer;

    public HighlightColor ShouldHighlight(Card card) =>
        HighlightColorHelper.GetHighlightColor(card.IsElemental(), card.IsBeast());
}

// Highlights 1 cost spells in one color, 2 cost spells in other and 3 cost spells in another
public class BarakKodobane : ICardWithHighlight
{
    public string GetCardId() => HearthDb.CardIds.Collectible.Hunter.BarakKodobane;

    public HighlightColor ShouldHighlight(Card card) =>
        HighlightColorHelper.GetHighlightColor(
            card is {Type: CardType.SPELL, Cost: 1},
            card is {Type: CardType.SPELL, Cost: 2},
            card is {Type: CardType.SPELL, Cost: 3}
        );
}
Enter fullscreen mode Exit fullscreen mode

In the example above, the params method allows for an arbitrary number of conditions to be passed. The caller doesn't need to worry about implementation details.

How it works:

  • At the call site: The params keyword allows you to pass any number of arguments of the specified type. If you pass more than one argument, they are automatically packed into an array.
  • At the method: The method receives an array of the specified type, and you can access the arguments as you would with any other array in C#.

Behind the Scenes:

  • Array Creation: When you use params, the compiler automatically creates an array to hold the passed arguments.
  • Performance: Because the arguments are converted to an array, the overhead is similar to any array creation in C#.
  • Flexibility: You can pass an array directly or provide a list of arguments, which makes it more flexible.

How Other Languages Solve the Same Problem

This problem is not new; most languages have a way to deal with it, and most solutions are very similar.

Java has varargs :

public static HighlightColor getHighlightColor(boolean... conditions) {}
Enter fullscreen mode Exit fullscreen mode

How it works:

  • At the call site: Java allows you to pass a comma-separated list of arguments of the specified type. Java internally converts this list into an array.
  • At the method: The method receives the arguments as an array of the specified type.

Behind the Scenes:

  • Array Creation: Similar to C#, varargs in Java are converted into an array. The array is created at runtime, and each argument is added to it.
  • Performance: The performance is largely similar to C# in that the arguments are stored in an array, which has a fixed overhead for array creation.
  • Overloading: One important distinction is that Java requires you to overload methods when you want to use other arguments alongside varargs. This can introduce complexity if not handled carefully.

Typescript has rest parameters:

function getHighlightColor(...conditions: boolean[]): HighlightColor {}
Enter fullscreen mode Exit fullscreen mode

How it works:

  • At the call site: The rest parameters syntax (...args) collects all passed arguments into a single array-like object.
  • At the method: Inside the method, you can treat the arguments as an array (or tuple, depending on the context).

Behind the Scenes:

  • Array Creation: TypeScript (and JavaScript) creates an array-like object to hold the arguments passed to the function. This is essentially a readonly array that cannot be resized after the function call.
  • Internally: The rest parameter syntax is a more declarative approach over the traditional arguments object, which allows for better type safety and readability.

Go has variadic functions using slices:

func getHighlightColor(conditions ...bool) int {}
Enter fullscreen mode Exit fullscreen mode

How it works:

  • At the call site: Go functions expect a slice (which can be created from an array or passed directly as an argument). You can use the ... operator to pass an array or slice to a function.
  • At the method: The function accepts a slice, which is a reference to an underlying array.

Behind the Scenes:

  • When you call func(1, 2, 3), the compiler takes those arguments, wraps them in a slice, and passes that slice to the function. Internally, Go has to allocate memory for the slice on the heap, so be mindful of the overhead when passing large numbers of arguments.
  • Slice Internals: Go's slices are reference types, so when you pass a slice to a function, you are passing a reference to the underlying array (not a copy). This is more memory efficient compared to passing an array by value.
  • Performance: Slices in Go are much more efficient than arrays because they are dynamically sized and use a reference to the underlying array. The slice itself contains three fields: a pointer to the array, a length, and a capacity.
  • Flexibility: Go's slices are more flexible as they allow efficient manipulation of data without needing to recreate a new array every time.

Python has *args and **kwargs:

def get_highlight_color(*conditions):
Enter fullscreen mode Exit fullscreen mode

How it works:

  • At the call site: Python allows you to pass a variable number of positional arguments using *args (denoted by a star *). If you want to pass keyword arguments (key-value pairs), you use **kwargs.
  • At the method: The method receives these arguments as a tuple (*args) and a dictionary (**kwargs), where *args holds all positional arguments and **kwargs holds all keyword arguments.

Behind the Scenes:

  • Argument Packing: In Python, when *args is used, the arguments are packed into a tuple. Similarly, **kwargs collects the keyword arguments into a dictionary.
  • Performance: The performance overhead of *args and **kwargs is relatively low compared to some other languages, as Python handles them with dynamic typing and doesn't involve the creation of strongly-typed arrays. However, there's still a small memory overhead because Python needs to create a tuple for *args and a dictionary for **kwargs to store the passed arguments.
  • Unpacking: Python allows you to unpack arguments passed to *args and **kwargs into other function calls, making it a flexible way to work with variable numbers of arguments.

Cherry on top of our solution

If you remember, our solution used Dictionary Mapping to get the correct HighlightColor depending on the conditions. Newer versions of C# allow us to use Pattern Matching to return the color based on the "shape" of the conditions array.

public static HighlightColor GetHighlightColor(params bool[] conditions)
{
    return conditions switch
    {
        // Matches when the first element is true
        [true, ..] => HighlightColor.Green,

        // Matches when the first element is false, second is true
        [false, true, ..] => HighlightColor.Yellow,

        // Matches when the first two elements are false, third is true
        [false, false, true, ..] => HighlightColor.Red,

        // Matches when the first three elements are false, fourth is true
        [false, false, false, true, ..] => HighlightColor.Blue,

        // Matches when the first four elements are false, fifth is true
        [false, false, false, false, true, ..] => HighlightColor.Pink,

        // Fallback for when no specific pattern matches
        _ => HighlightColor.None
    };
}
Enter fullscreen mode Exit fullscreen mode

The .. in the pattern matching syntax is called the "discard" or "rest" pattern, and it is used to match and ignore any remaining elements in the array. It allows you to match a specific subset of elements in a collection while ignoring the rest. For example, [true, ..] matches when the first element is true, but it doesn't care what the rest of the elements are, allowing you to match any sequence where the first boolean is true, returning HighlightColor.Green.

The _ is a wildcard that acts as a fallback when none of the patterns are met, returning HighlightColor.None.

Conclusion

In this article, we've explored how software requirements change over time and how C# solves the problem of handling a variable number of inputs to a method. We've also looked at how other languages approach the same problem. This approach not only makes your code more flexible and scalable but also keeps it clean, maintainable, and easy to extend as your requirements evolve. Whether we're adding more conditions or introducing new colors, this solution provides a simple and powerful way to handle dynamic, condition-based logic in our application.

Top comments (0)