Loading learning content...
One of polymorphism's most powerful applications is its ability to treat different types uniformly within collections. A List<Shape> can contain circles, rectangles, triangles, and any future shape type—all processable through a single loop. A List<PaymentMethod> can hold credit cards, PayPal accounts, and cryptocurrency wallets, each handling payments its own way.
This capability—storing and processing heterogeneous objects through a common interface—is called polymorphic collections. It's fundamental to flexible, extensible software design and appears throughout professional codebases.
Polymorphic collections combine the organizational power of collections with the behavioral flexibility of polymorphism, enabling patterns that would be impossible or extremely cumbersome with homogeneous typing.
By the end of this page, you will understand how to design and use polymorphic collections, how iteration invokes polymorphic behavior, common patterns like heterogeneous processing and extensible systems, and practical applications in real software.
A polymorphic collection is a collection whose elements are typed to an interface or base class, but contain instances of various concrete types. When you iterate over the collection and call methods, each element executes its own implementation—that's polymorphism at work.
12345678910111213141516171819202122
// Polymorphic collection: typed to interface, contains various implementationsList<Drawable> canvas = new ArrayList<>(); // Add different shapes—all implement Drawablecanvas.add(new Circle(50, 50, 25)); // Circle implementationcanvas.add(new Rectangle(100, 100, 50, 30)); // Rectangle implementationcanvas.add(new Line(0, 0, 200, 200)); // Line implementationcanvas.add(new Text("Hello", 150, 50)); // Text implementation // Polymorphic iteration: each element draws itself its own wayfor (Drawable shape : canvas) { shape.draw(graphics); // Dynamic dispatch to each type's draw() method} // What happens at runtime:// 1. Circle.draw() is called for the first element// 2. Rectangle.draw() is called for the second element// 3. Line.draw() is called for the third element// 4. Text.draw() is called for the fourth element // The loop code doesn't know or care about concrete types// It works with the Drawable interface onlyThe key insight is that the collection type (List<Drawable>) determines what methods are available for calling, while the actual element types (Circle, Rectangle, etc.) determine which implementations execute. This separation is what makes polymorphic collections so powerful.
| Aspect | Determined By | Effect |
|---|---|---|
| Available methods | Collection element type (Drawable) | Can only call Drawable methods |
| Method implementation | Actual object type (Circle, etc.) | Each object's own method runs |
| Type checking | Compile-time against interface | Compiler ensures type safety |
| Behavior | Runtime polymorphism | Different behaviors for different types |
Let's build a complete example: a notification system that sends alerts through multiple channels. Each notification channel (email, SMS, push) implements the same interface but delivers notifications differently:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
// Step 1: Define the common interfaceinterface NotificationChannel { void send(Notification notification); boolean isAvailable(); String getChannelName();} // Step 2: Implement concrete channelsclass EmailChannel implements NotificationChannel { private final EmailService emailService; public EmailChannel(EmailService emailService) { this.emailService = emailService; } @Override public void send(Notification notification) { emailService.sendEmail( notification.getRecipient().getEmail(), notification.getSubject(), notification.getMessage() ); } @Override public boolean isAvailable() { return emailService.isConnected(); } @Override public String getChannelName() { return "Email"; }} class SmsChannel implements NotificationChannel { private final SmsGateway smsGateway; public SmsChannel(SmsGateway smsGateway) { this.smsGateway = smsGateway; } @Override public void send(Notification notification) { smsGateway.sendSms( notification.getRecipient().getPhoneNumber(), notification.getMessage() ); } @Override public boolean isAvailable() { return smsGateway.hasCredits(); } @Override public String getChannelName() { return "SMS"; }} class PushNotificationChannel implements NotificationChannel { private final PushService pushService; public PushNotificationChannel(PushService pushService) { this.pushService = pushService; } @Override public void send(Notification notification) { pushService.sendPush( notification.getRecipient().getDeviceTokens(), notification.getTitle(), notification.getMessage() ); } @Override public boolean isAvailable() { return true; // Push is always available } @Override public String getChannelName() { return "Push Notification"; }} class SlackChannel implements NotificationChannel { private final SlackClient slackClient; private final String channelId; public SlackChannel(SlackClient slackClient, String channelId) { this.slackClient = slackClient; this.channelId = channelId; } @Override public void send(Notification notification) { slackClient.postMessage(channelId, notification.getMessage()); } @Override public boolean isAvailable() { return slackClient.isAuthenticated(); } @Override public String getChannelName() { return "Slack"; }}Now we can create a polymorphic collection of channels and process them uniformly:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
// Step 3: Use polymorphic collectionclass NotificationService { private final List<NotificationChannel> channels; private final Logger logger; public NotificationService(List<NotificationChannel> channels) { this.channels = channels; this.logger = LoggerFactory.getLogger(getClass()); } // Send through all available channels public void broadcast(Notification notification) { for (NotificationChannel channel : channels) { if (channel.isAvailable()) { try { channel.send(notification); // Polymorphic call logger.info("Sent via {}", channel.getChannelName()); } catch (Exception e) { logger.error("Failed to send via {}: {}", channel.getChannelName(), e.getMessage()); } } else { logger.warn("{} is not available", channel.getChannelName()); } } } // Send through first available channel public boolean sendFirst(Notification notification) { for (NotificationChannel channel : channels) { if (channel.isAvailable()) { try { channel.send(notification); // Polymorphic call return true; } catch (Exception e) { // Try next channel } } } return false; // All channels failed } // Get status of all channels public Map<String, Boolean> getChannelStatus() { Map<String, Boolean> status = new LinkedHashMap<>(); for (NotificationChannel channel : channels) { status.put(channel.getChannelName(), channel.isAvailable()); } return status; }} // Usage: configure which channels to useList<NotificationChannel> channels = new ArrayList<>();channels.add(new EmailChannel(emailService));channels.add(new SmsChannel(smsGateway));channels.add(new PushNotificationChannel(pushService));channels.add(new SlackChannel(slackClient, "#alerts")); NotificationService notificationService = new NotificationService(channels); // Broadcast goes to ALL channelsnotificationService.broadcast(urgentNotification);One of the greatest benefits of polymorphic collections is extensibility without modification. When a new notification channel is needed (e.g., Discord, Teams, webhook), we simply create a new class and add it to the collection—no changes to NotificationService:
1234567891011121314151617181920212223242526272829303132333435363738
// Six months later: add Discord supportclass DiscordChannel implements NotificationChannel { private final DiscordWebhook webhook; public DiscordChannel(DiscordWebhook webhook) { this.webhook = webhook; } @Override public void send(Notification notification) { webhook.send(new DiscordEmbed( notification.getTitle(), notification.getMessage(), notification.getSeverity().getColor() )); } @Override public boolean isAvailable() { return webhook.isConfigured(); } @Override public String getChannelName() { return "Discord"; }} // Adding the new channel requires NO changes to NotificationServicechannels.add(new DiscordChannel(discordWebhook)); // The notification service automatically supports Discord now// It iterates over all channels including the new onenotificationService.broadcast(notification); // This is the Open/Closed Principle in action:// - Open for extension: new channels can be added// - Closed for modification: NotificationService code unchangedWith polymorphic collections, new capabilities are added by creating new classes and adding them to the collection. The processing code—often the most complex part—remains completely unchanged. This dramatically reduces the risk and cost of extensions.
Polymorphic collections enable several powerful processing patterns. Here are the most common ones, each leveraging polymorphism to handle heterogeneous objects uniformly:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
// PATTERN 1: Apply to All (Broadcast)// Execute the same operation on every elementfor (Validator validator : validators) { validator.validate(input); // Each validator checks differently} // PATTERN 2: Filter and Process// Process only elements meeting criteriafor (Task task : tasks) { if (task.isReady()) { // Polymorphic check task.execute(); // Polymorphic execution }} // PATTERN 3: Find First Match// Return the first element that succeedsfor (Handler handler : handlers) { if (handler.canHandle(request)) { // Polymorphic check return handler.handle(request); // Polymorphic handling }}throw new NoHandlerException(request); // PATTERN 4: Chain of Responsibility// Pass through all handlers that can contributeObject result = request;for (Processor processor : processors) { result = processor.process(result); // Each transforms the result}return result; // PATTERN 5: Collect Results// Gather results from all elementsList<Result> results = new ArrayList<>();for (Analyzer analyzer : analyzers) { Result r = analyzer.analyze(data); // Each analyzes differently results.add(r);}return aggregateResults(results); // PATTERN 6: Vote/Consensus// Combine decisions from multiple sourcesint yesVotes = 0;for (Decider decider : deciders) { if (decider.decide(proposal)) { // Each decides differently yesVotes++; }}return yesVotes > deciders.size() / 2; // Majority winsEach pattern uses the same mechanism—polymorphic method calls within iteration—but applies it to different goals. The beauty is that none of these patterns need to know the concrete types of the elements they process.
Modern Java's Stream API works beautifully with polymorphic collections, enabling concise, expressive processing of heterogeneous objects:
12345678910111213141516171819202122232425262728293031323334353637383940
// Polymorphic collectionList<Shape> shapes = getShapes(); // Contains circles, rectangles, triangles, etc. // Calculate total area (polymorphic area() calls)double totalArea = shapes.stream() .mapToDouble(Shape::area) // Each shape calculates its own area .sum(); // Filter by area and sort by perimeterList<Shape> largeShapesSorted = shapes.stream() .filter(s -> s.area() > 100) // Polymorphic filter .sorted(Comparator.comparingDouble(Shape::perimeter)) // Polymorphic sort .collect(Collectors.toList()); // Group shapes by type (using polymorphic getType())Map<ShapeType, List<Shape>> shapesByType = shapes.stream() .collect(Collectors.groupingBy(Shape::getType)); // Find any shape that contains a pointOptional<Shape> containingShape = shapes.stream() .filter(s -> s.contains(point)) // Polymorphic containment check .findFirst(); // Parallel polymorphic processingdouble averageArea = shapes.parallelStream() .mapToDouble(Shape::area) // Parallel polymorphic calls .average() .orElse(0.0); // Complex pipeline: transform, filter, collectList<ShapeInfo> shapeInfos = shapes.stream() .filter(Shape::isVisible) // Polymorphic visibility check .map(s -> new ShapeInfo( s.getType(), // Polymorphic type s.area(), // Polymorphic area s.perimeter(), // Polymorphic perimeter s.getBoundingBox() // Polymorphic bounds )) .sorted(Comparator.comparingDouble(ShapeInfo::getArea).reversed()) .collect(Collectors.toList());Method references like Shape::area work polymorphically. When the stream encounters a Circle, it calls Circle.area(); for a Rectangle, it calls Rectangle.area(). The method reference is resolved at runtime based on actual types.
Sometimes you need to perform type-specific operations on elements in a polymorphic collection. There are several approaches, from acceptable to preferred:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
// APPROACH 1: instanceof checks (sometimes necessary, often avoidable)for (Shape shape : shapes) { if (shape instanceof Circle) { Circle circle = (Circle) shape; System.out.println("Circle radius: " + circle.getRadius()); } else if (shape instanceof Rectangle) { Rectangle rect = (Rectangle) shape; System.out.println("Rectangle dimensions: " + rect.getWidth() + "x" + rect.getHeight()); }}// Problem: Violates Open/Closed—new types require new branches // ============================================ // APPROACH 2: Pattern matching (Java 16+)for (Shape shape : shapes) { switch (shape) { case Circle c -> System.out.println("Radius: " + c.getRadius()); case Rectangle r -> System.out.println("Area: " + r.getWidth() * r.getHeight()); case Triangle t -> System.out.println("Base: " + t.getBase()); default -> System.out.println("Unknown shape"); }}// Cleaner syntax, but still requires modification for new types // ============================================ // APPROACH 3: Visitor pattern (when type-specific operations are common)interface ShapeVisitor { void visit(Circle circle); void visit(Rectangle rectangle); void visit(Triangle triangle);} interface Shape { void accept(ShapeVisitor visitor); // Double dispatch} class Circle implements Shape { @Override public void accept(ShapeVisitor visitor) { visitor.visit(this); // Calls visit(Circle) }} class ShapeInfoPrinter implements ShapeVisitor { @Override public void visit(Circle circle) { System.out.println("Circle with radius: " + circle.getRadius()); } @Override public void visit(Rectangle rectangle) { System.out.println("Rectangle: " + rectangle.getWidth() + "x" + rectangle.getHeight()); } @Override public void visit(Triangle triangle) { System.out.println("Triangle with base: " + triangle.getBase()); }} // UsageShapeVisitor printer = new ShapeInfoPrinter();for (Shape shape : shapes) { shape.accept(printer); // Type-specific behavior without instanceof} // ============================================ // APPROACH 4: Polymorphic method (best when possible)// Add the operation to the interfaceinterface Shape { String getDescription(); // Each type describes itself} class Circle implements Shape { @Override public String getDescription() { return "Circle with radius: " + radius; }} // Now type-specific logic is encapsulated in each classfor (Shape shape : shapes) { System.out.println(shape.getDescription()); // No type checking needed}When you find yourself writing instanceof checks, ask: 'Can this behavior be pushed into the classes themselves?' Often the answer is yes, and the result is cleaner, more extensible code.
Polymorphic collections are fundamental to how modern frameworks work. Many frameworks iterate over collections of handlers, filters, or processors to provide extensibility:
| Framework | Collection Type | Purpose |
|---|---|---|
| Servlet API | List<Filter> | Request/response processing chain |
| Spring MVC | List<HandlerInterceptor> | Pre/post request handling |
| Spring Security | List<AuthenticationProvider> | Multiple auth mechanisms |
| JUnit 5 | List<Extension> | Test lifecycle extensions |
| Log4j/SLF4J | List<Appender> | Multiple log destinations |
| Jackson | List<Module> | JSON processing plugins |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
// Spring's Filter chain processes HTTP requests// Each filter is called in order—polymorphic doFilter() callspublic class FilterChainProxy implements Filter { private List<Filter> filters; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { // Wrap in virtual filter chain VirtualFilterChain virtualChain = new VirtualFilterChain(chain, filters); virtualChain.doFilter(request, response); } private class VirtualFilterChain implements FilterChain { private final List<Filter> additionalFilters; private int currentPosition = 0; @Override public void doFilter(ServletRequest request, ServletResponse response) { if (currentPosition < additionalFilters.size()) { Filter nextFilter = additionalFilters.get(currentPosition++); nextFilter.doFilter(request, response, this); // Polymorphic call } else { originalChain.doFilter(request, response); } } }} // ============================================ // JUnit 5 extension mechanismpublic class JupiterTestEngine { private List<Extension> extensions; public void executeTest(TestDescriptor descriptor) { // Notify all extensions before test for (Extension extension : extensions) { if (extension instanceof BeforeEachCallback) { ((BeforeEachCallback) extension).beforeEach(context); } } // Run test invoke(descriptor); // Notify all extensions after test for (Extension extension : extensions) { if (extension instanceof AfterEachCallback) { ((AfterEachCallback) extension).afterEach(context); } } }} // ============================================ // Jackson module registration for JSON processingObjectMapper mapper = new ObjectMapper();mapper.registerModules( new JavaTimeModule(), // Date/time support new Jdk8Module(), // Optional support new ParameterNamesModule() // Parameter name access); // Internally, Jackson iterates over modules:for (Module module : registeredModules) { module.setupModule(context); // Each module configures itself}Many plugin architectures are built on polymorphic collections. Plugins implement a common interface and are added to a collection; the framework iterates over them, invoking their methods. This enables extensibility without modifying framework code.
When designing systems with polymorphic collections, several considerations affect quality and maintainability:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
// Good interface design: all methods meaningful for all implementationsinterface PaymentProcessor { boolean supports(PaymentMethod method); // All processors can check this PaymentResult process(Payment payment); // All processors can do this void refund(String transactionId); // All processors can refund} // Bad interface design: some methods don't apply to all implementationsinterface PaymentProcessor { PaymentResult process(Payment payment); void setApiKey(String key); // Not all need API keys void configureBankAccount(...); // Only bank transfers need this CryptoWallet getWallet(); // Only crypto payments have wallets} // ============================================ // Immutable collection for safetypublic class ValidationService { private final List<Validator> validators; public ValidationService(List<Validator> validators) { // Defensive copy to immutable list this.validators = List.copyOf(validators); } public ValidationResult validate(Input input) { // Safe iteration—collection can't be modified for (Validator v : validators) { // ... } }} // ============================================ // Error handling: continue despite failurespublic class MultiNotifier { private final List<Notifier> notifiers; public NotificationReport notifyAll(Event event) { List<String> successes = new ArrayList<>(); List<String> failures = new ArrayList<>(); for (Notifier n : notifiers) { try { n.notify(event); successes.add(n.getName()); } catch (Exception e) { failures.add(n.getName() + ": " + e.getMessage()); // Continue with remaining notifiers } } return new NotificationReport(successes, failures); }}Polymorphic collections combine the organizing power of collections with the behavioral flexibility of polymorphism. They enable heterogeneous objects to be stored together and processed uniformly, forming the basis for extensible, maintainable systems.
What's next:
In the final page of this module, we'll explore real-world polymorphism examples—comprehensive case studies showing how polymorphism patterns combine in production systems to solve complex design challenges.
You now understand polymorphic collections—a powerful technique for storing and processing heterogeneous objects uniformly. This pattern is essential for building extensible systems and is used throughout professional frameworks and libraries.