Inheritance, Polymorphism, and Composition in Dart and Flutter
There is a trend in software development away from the deep, branching class trees popular with object-oriented languages. Some believe newer functional paradigms should outright replace OOP in software design. This leaves many with the idea that inheritance has no place in software construction any longer, or that its use should be strictly limited. UI frameworks still make heavy use of inheritance as a key tool to avoid code redundancy, and the Flutter framework is no exception. Even so, when you write Dart code that isn't directly extending the UI framework, it is best to keep your inheritance chains shallow and prefer composition when it makes sense, but you shouldn't ignore the power of inheritance and polymorphism altogether.
What, exactly, do we mean by these terms? Inheritance is the ability of a class to inherit properties and methods from a superclass (and from the superclass's superclass, and so on). Polymorphism is exemplified in Dart by the @override
metatag. With it, a subclass's implementation of an inherited behavior can be specialized to be appropriate to its more specific subtype. When a class has properties that are themselves instances of other classes, it's using composition to add to its abilities.
The code for this article was tested with Dart 2.8.4 and Flutter 1.17.5.
Inheritance and polymorphism in Flutter
The Flutter UI framework, all written in open-source Dart, is full of examples of inheritance and polymorphism. In fact, many of the standard patterns in building Flutter apps rely on these concepts:
class MyWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container();
}
}
This is the simplest example of a custom widget in Flutter. Note that it uses the extends
keyword to indicate that the class should inherit properties and methods from StatelessWidget, which itself inherits from the Widget class. This is important, because every Flutter widget has a build()
method available that returns an instance of Widget. The @override
metatag helps you identify inherited methods or variables that are being overridden (replaced) in your subclass.
In this barebones example, build()
does not return a plain Widget. It returns a Container. This works because of polymorphism. The method will happily return any object that has Widget somewhere in its ancestry, allowing it to be flexible but still type safe (it will not return non-widgets). A Container is a Widget, and so is MyWidget, and they can all be counted on to contain some implementation of build()
.
Ancestry of StatelessWidget: Object > Diagnosticable > DiagnosticableTree > Widget > StatelessWidget
Everything in Dart extends Object, so parameters typed as Object will accept any value. Inheritance and polymorphism are at the very core of the language. Container extends StatelessWidget which extends Widget, so build()
can return a Container object, and containers can be included in List collections typed as Widget:
class MyWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Container(),
Container(),
Placeholder(),
],
);
}
}
Column widgets accept a children
parameter typed as a List of Widget objects. Including <Widget>
before the list literal indicates that you want only widgets in the list. The Dart code analyzer will complain if a non-widget is included. Both Container and Placeholder are widgets, so this is valid code.
Now that we've seen an example of how Flutter uses Dart's OOP features, let's look at how you can benefit from them in your own data models.
Inheritance and polymorphism in data models
Just as Flutter uses core OOP principles to model the UI, you can use them to model your data. Let's create a data construct to hold the content of a chat message and another that includes an image with the text:
class Message {
String text;
Message({@required this.text});
}
class ImageMessage extends Message {
String imageUrl;
ImageMessage({@required String text, @required this.imageUrl}) :
super(text: text);
}
It's not a lot of code, but there's a lot going on in this example. First we create a Message class to contain something like a chat message. A message is nothing without its text, so we make sure passing in the text is required, and we use a named parameter for extra clarity.
The ImageMessage class extends Message, which means it inherits the text
property. Constructors in Dart are not inherited, so we give ImageMessage its own constructor, and it needs to accept two values labeled text
and imageUrl
. Since text
is technically part of Message, not ImageMessage, we can't use automatic initialization for it, and we need to specify its type (String) so Dart doesn't accept just any type of value there.
Following the ImageMessage constructor's parameter list, we add a colon, after which Dart will expect an initializer list. This is a comma-delimited list of initializers for our objects, often used to initialize properties before any constructor body code runs, which is required with final
properties. Here, we use the super
keyword to call the constructor of the ImageMessage class's superclass, which is Message. The Message constructor will handle assigning the text
argument to the text
instance variable.
To illustrate how inheritance and polymorphism can benefit us, we'll need a list of example messages to work with:
final messages = <Message>[
Message(text: "Message 1"),
Message(text: "Message 2"),
ImageMessage(
text: "Message 3",
imageUrl: "https://example.com/image1.jpg",
),
];
Though typed as a collection of Message objects using Dart generics, messages
will accept any object with the Message class in its ancestry.
Now we can create a widget to display the list of messages, and it will be able to handle either type of message while rejecting anything that is not a Message:
class MessageList extends StatelessWidget {
final List<Message> messages;
const MessageList({Key key, @required this.messages}) :
super(key: key);
@override
Widget build(BuildContext context) {
return Column(
children: messages.map((Message msg) {
final text = Text(msg.text);
if (msg is ImageMessage) {
return Row(
children: <Widget>[
Image.network(msg.imageUrl),
text,
],
);
}
return text;
}).toList(),
);
}
}
MessageList accepts a List of Message objects through its constructor, then uses those to create UI elements. Like all Flutter widgets, this one includes an overridden build()
method that the framework calls during the appropriate point in the widget's lifecycle.
A Column widget displays its children sequentially in a vertical column. Each child must be a widget. We use the List class's map()
method to produce these widgets. The map()
method loops through each element of messages
, passing each to an anonymous function provided as its only argument. The anonymous function first builds a Text widget, because every message needs that. If the current Message object is actually an instance of ImageMessage, we return a Row that includes both the image and the text widget. Otherwise, we simply return the text widget.
Next, we'll learn about an alternative technique you can use to add abilities to your classes.
Composition in Flutter
Inheritance and polymorphism expand a class's behavior through a class hierarchy, with properties and methods passed down through the generations. Composition is a more modular approach in which a class contains instances of other classes that bring their own abilities with them. Composition is used extensively in Flutter's UI framework. If you were to examine the code for Flutter's Container widget, you'd find it's composed of many other widgets, the exact nature of which depend on the arguments passed to the Container during instantiation. Let's take another look at the MyWidget class from earlier:
class MyWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Container(),
Container(),
Placeholder(),
],
);
}
}
An instance of MyWidget is a StatelessWidget, but in a sense, it is composed of a Column, two Container widgets, and a Placeholder. Through composition, we define what a MyWidget is. Many Flutter widgets, custom and core, are thin wrappers around a bunch of child widgets. This allows a great deal of flexibility and power when constructing an app's UI.
One of the most obvious ways Flutter makes use of composition is through the child pattern, which allows developers to customize the composition of framework objects.
The child pattern
When building Flutter apps, you'll often encounter widgets that take a child
argument (or children
if it can handle more than one). In this way, widgets are composable. Imagine a button widget that accepts only a string label. In order to make the button's label customizable, it would need to accept a large number of other arguments allowing you to set properties like color, size, etc., and it would never be able to handle everything.
In Flutter, button widgets accept any Widget object as a child. Most often, it's a Text widget, but it could be anything, and the button doesn't need to reimplement all the customization options already available in Text or other widgets. Through composition, Flutter lets you easily create almost any UI you can imagine. When creating your own widgets, you should keep this pattern in mind.
An example button instantiation using the child pattern:
FlatButton(
child: Text(
"Save",
style: TextStyle(
color: Colors.green,
fontSize: 12,
),
),
)
Here, we've passed an instance of the Text widget with a custom style to act as a label for the FlatButton widget, but in theory we could have used any widget, even one of our own making. The child here could be a chart, a map, an icon, or an image, to name a few possibilities, as the FlatButton will accommodate any widget at all.
Composition in data models
Sometimes it makes sense to use composition instead of inheritance in your data models. As an example, consider a game application where a player must destroy a series of aliens. First, you might declare a few templates for different alien types:
class AlienTemplate {
final int health;
AlienTemplate({this.health});
}
class ArmoredAlienTemplate extends AlienTemplate {
final int armor;
ArmoredAlienTemplate({int health, this.armor}) :
super(health: health);
}
class EliteAlienTemplate extends ArmoredAlienTemplate {
final String name;
EliteAlienTemplate({int health, int armor, this.name}) :
super(health: health, armor: armor);
}
For the templates, inheritance is the best option, because every alien needs the health
property, and more specialized aliens have additional properties. Each specialized template is an AlienTemplate since they all have that class in their ancestry. We could create a collection of these templates somewhere, which we could conveniently type as AlienTemplate, and it could hold any of our template instances. Note that for brevity, I've not included the @required
metatag on any parameters, but you certainly should where appropriate.
The templates include only final
properties, which means the properties cannot be modified once initialized; fitting for templates. Immutable data models like this are a best practice, but now we need a way to alter an alien's health during the course of a game. This is where composition comes in:
class LiveAlien {
final AlienTemplate template;
int currentHealth;
LiveAlien(this.template) {
currentHealth = template.health;
}
}
This class does not extend another. A live alien is not a template, so it would be counterintuitive for it to descend from any of the template classes. Live aliens need to know which template they're based on, and they shouldn't be able to modify a template's properties. The mutable property currentHealth
is initialized from a template's health value, and it can be modified later to account for injuries since it is not a final
property. With this pattern, it's possible to build any number of LiveAlien instances, and each can be built using any of the templates. A live alien is composed of a template and a current health value.
Inheritance relationships are often described as is-a relationships; an ArmoredAlienTemplate is an AlienTemplate. Composition relationships are has-a relationships; a LiveAlien has an AlienTemplate. Use all of these concepts when constructing your own classes, remembering that each is best for different situations.
Conclusion
You've just learned how Dart's OOP features can help you create your own data types, and that's what OOP is really all about.