Combining Generics with the Strategy Pattern for Type-Safe Algorithm Selection

In modern software engineering, design patterns like Strategy and Factory help decouple code, making it more flexible and easier to maintain. Generics further enhance these patterns by providing compile-time type safety, reducing the risk of runtime errors. In this article, we’ll focus on integrating Generics with the Strategy Pattern, walking through a clear example to illustrate the benefits and verify correctness.
What is the Strategy Pattern?
The Strategy Pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. By separating the algorithm from the code that uses it, you gain the flexibility to switch strategies at runtime without modifying the client code.
For example, consider a scenario where you have different ways to process a list of objects:
- Sorting algorithms (e.g., QuickSort, MergeSort)
- Filtering strategies (e.g., filtering by a certain property)
- Transformation strategies (e.g., converting data from one format to another)
All these can be represented as “strategies” conforming to a common interface.
Why Use Generics?
When you use the Strategy Pattern without Generics, you might have to rely on casting or accept Object
-typed parameters, losing some type safety. Generics allow you to define your strategies in a type-safe manner, ensuring that each strategy can only operate on a specific data type. This prevents ClassCastException
s and improves code readability by making it clear what types each strategy expects.
Example: Processing Lists of Different Data Types
Let’s say we need to process lists of various data types. We’ll create a ProcessingStrategy
interface that defines a single method to process a List<T>
and return a result of type R
. Our strategies will implement different behavior (e.g., summing integers, concatenating strings, or filtering out null elements), all while remaining type-safe.
Step 1: Define the Strategy Interface
public interface ProcessingStrategy<T, R> {
R process(List<T> items);
}
Here, T
is the input item type, and R
is the result type after processing the list. This generic interface ensures each implementation is type-safe.
Step 2: Implement Specific Strategies
Example 1: Summation Strategy
This strategy operates on a list of integers and returns the sum of all elements.
public class SumIntegersStrategy implements ProcessingStrategy<Integer, Integer> {
@Override
public Integer process(List<Integer> items) {
int sum = 0;
for (Integer i : items) {
if (i != null) {
sum += i;
}
}
return sum;
}
}
Example 2: Concatenate Strings Strategy
This strategy takes a list of strings and concatenates them into a single string.
public class ConcatenateStringsStrategy implements ProcessingStrategy<String, String> {
@Override
public String process(List<String> items) {
StringBuilder sb = new StringBuilder();
for (String s : items) {
if (s != null) {
sb.append(s);
}
}
return sb.toString();
}
}
Step 3: Using a Factory to Obtain a Strategy (Optional Enhancement)
Although we’re focusing on the Strategy Pattern, you can optionally use a Factory or a simple registry to obtain the correct strategy based on application context. This is not mandatory, but it shows how these patterns can work together.
import java.util.HashMap;
import java.util.Map;
public class StrategyFactory {
private static final Map<Class<?>, ProcessingStrategy<?,?>> strategies = new HashMap<>();
static {
// Register strategies with the factory
strategies.put(Integer.class, new SumIntegersStrategy());
strategies.put(String.class, new ConcatenateStringsStrategy());
}
@SuppressWarnings("unchecked")
public static <T, R> ProcessingStrategy<T, R> getStrategy(Class<T> type) {
// The strategy for the given type must match T and its corresponding R.
// We use a cast here because of how Generics are stored, but we know it's safe
// because we control what we put into the map.
return (ProcessingStrategy<T, R>) strategies.get(type);
}
}
Note: The @SuppressWarnings("unchecked")
annotation is used here because the map is storing raw ProcessingStrategy<?,?>
instances. We know they are correctly mapped based on the class type, ensuring type safety in practice.
Step 4: Client Code Demonstration
import java.util.Arrays;
import java.util.List;
public class ClientExample {
public static void main(String[] args) {
List<Integer> integers = Arrays.asList(1, 2, 3, 4);
ProcessingStrategy<Integer, Integer> integerStrategy = StrategyFactory.getStrategy(Integer.class);
Integer sum = integerStrategy.process(integers);
System.out.println("Sum of integers: " + sum); // Should print 10
List<String> strings = Arrays.asList("Hello, ", "Generics", " with ", "Strategy!");
ProcessingStrategy<String, String> stringStrategy = StrategyFactory.getStrategy(String.class);
String concatenated = stringStrategy.process(strings);
System.out.println("Concatenated string: " + concatenated);
// Should print "Hello, Generics with Strategy!"
}
}
Verifying Correctness
Type Safety at Compile Time:
Each strategy is parameterized with T
and R
. The integer strategy only accepts List<Integer>
, and the string strategy only accepts List<String>
. If you try to use a strategy with the wrong type, the code won’t compile, catching errors early.
No Unnecessary Casting in Strategies:
Inside SumIntegersStrategy
, we directly use Integer i
from the list. There’s no need for (Integer) item
casting. The Generics guarantee that the list only contains integers.
Correctness of Results:
- For the integer list
[1,2,3,4]
, the sum is indeed 10. - For the string list
["Hello, ", "Generics", " with ", "Strategy!"]
, the concatenation is correct and yields"Hello, Generics with Strategy!"
.
Flexible Extension:
Adding a new strategy, say FilterNullsStrategy<T>
that removes null elements before returning the filtered list, would simply involve creating a new class that implements ProcessingStrategy<T, List<T>>
, and then registering it with the factory. The rest of the code remains unchanged.
Conclusion
By pairing Generics with the Strategy Pattern, we achieve a robust design that is both flexible and type-safe. This combination ensures that incorrect type usage is caught at compile time, making the system safer and easier to maintain. Moreover, extending the system with new strategies or different data types becomes straightforward and low-risk. This pattern is not limited to processing lists — it can be applied to a wide range of algorithms and use cases where you need the freedom to switch behavior at runtime without sacrificing type safety.