Classes and Objects
Learning Objectives
- You know how to define classes and create objects in Dart.
- You know of inheritance and how to extend classes in Dart.
- You know how to define enums and use them as types.
Dart is an object-oriented programming language. Like many object-oriented programming languages, it offers ways to couple concepts together as well as to create entities that are used to divide and conquer aspects of a program.
Defining a class and creating an object
Classes in Dart are defined using the keyword class
, which is followed by the name of the class and the block that contains the class definition. The class definition block can contain instance properties, instance methods, and constructors.
The following example defines a class called Person
that has a name and a year of birth. The class also has a constructor that takes the name and the year of birth as parameters, and a method toString
that returns a string representation of the object. The @override
annotation is used to indicate that the method is overriding a method from the parent class — like in e.g. Java, all classes in Dart inherit from the class Object
.
class Person {
String name;
int yearOfBirth;
Person(this.name, this.yearOfBirth);
@override
String toString() {
return "$name ($yearOfBirth)";
}
}
Creating an instance of a class is done by calling a constructor. The following program shows creating an instance of the class Person
, and then printing the object using the toString
method.
Unlike e.g. with Java, a new
keyword is not used when calling a constructor.
Instance properties
Each object has its own values for the instance properties. Instance properties of an object are accessed using the dot notation, where the name of the object comes before the dot and the name of the instance property comes after the dot.
As an example, the following program would create two instances of the class Person
and print their names.
void main() {
final jean = Person("Jean Sibelius", 1865);
final alvar = Person("Alvar Aalto", 1898);
print(jean.name);
print(alvar.name);
}
Instance methods
Instance methods are functions that have access to the instance properties of the object that the method is invoked on. In the following example, we define an instance method that is used to determine whether the person has been born after another person.
The example also demonstrates defining the type of return values as well as the type of the parameters. If we would wish to be explicit about instance properties, we could use the keyword this
, writing the function bornAfter
as follows.
bool bornAfter(Person person) {
return this.yearOfBirth > person.yearOfBirth;
}
Comparing for equality
Objects can be compared with the ==
operator. By default, for objects, the ==
operator compares the memory addresses of the objects. Even if two objects have the same values for their instance properties, they are not considered equal if they are not the same object in memory.
To compare two objects for equality, we need to override the ==
operator. The following example demonstrates overriding the ==
operator for the class Person
. The ==
operator is overridden to compare the name and the year of birth of the objects.
Constructors
In the above examples, we used a constructor that sets instance property values automatically, at least from the perspective of e.g. Java programmers (prior to the introduction of record classes). There was no explicit need to define a constructor block to assign the instance property values.
When we write a constructor that follows the form Name(this.property, this.another, ...)
, we override a default constructor inherited from Object. Each class can have only one default constructor, and defining multiple constructors in the said format leads to an error.
Classes can have multiple constructors, but only one default constructor (that shares the name of the class). Additional constructors can be created as named constructors, where each constructor has a specific name.
Named constructors
Creating a named constructor is done by providing the class name followed by a dot and the name for the constructor, which is then followed by the parameters.
In the following example, we define a named constructor unknownBirthYear
to the class Person
, where the birth year would then be set to -9999
upon creation — the named constructor is followed by a colon, which is followed by initialization of properties that are not directly initialized in the constructor.
It is also possible to call another constructor from a constructor. This is done by using the this
keyword followed by the name of the constructor. The following example demonstrates calling the default constructor from the named constructor unknownBirthYear
.
class Person {
String name;
int yearOfBirth;
Person(this.name, this.yearOfBirth);
Person.unknownBirthYear(this.name) : this(name, -9999);
// ...
}
Named arguments and default values
Like when working with functions, it is also possible to explicitly define the names of the arguments when calling a constructor. This makes the use of specific arguments more explicit. In the following example, we adjust the named constructor unknownBirthYear
of the class Person
so that it takes the name of the person as a named argument, using “Unknown” as the default value.
Inheritance
Dart supports single inheritance, meaning that a class can inherit from only one superclass. In Dart, the extends
keyword is used to indicate that a class is a subclass of another class. The following example demonstrates defining a class Student
that extends the class Person
.
class Student extends Person {
String studentNumber;
Student(String name, int yearOfBirth, this.studentNumber) : super(name, yearOfBirth);
@override
String toString() {
return "$name ($yearOfBirth) - $studentNumber";
}
}
There exists also ready-made classes that can be extended to add functionality to programs. As an example, the package equatable provides a class Equatable that can be inherited to generate the functionality for comparing equality of objects.
The following example shows using the Equatable
class. In the example, we define a class Task
that extends the Equatable
class. The Task
class has a name and a description, and the props
method is overridden to return the properties that are used to compare the objects for equality.
Try the above example without extending the Equatable
class and see what happens when comparing the objects for equality.
Sealed classes
Dart also has a concept of sealed classes. Sealed classes can be inherited only from within the same file in which the sealed class is defined — this allows restricting the subclasses of a class. The sealed
keyword is used to define a sealed class
The following example demonstrates defining a sealed class Shape
that has subclasses Square
and Triangle
.
sealed class Shape {
}
class Square extends Shape {
final double side;
Square(this.side);
}
class Triangle extends Shape {
final double base;
final double height;
Triangle(this.base, this.height);
}
Sealed classes allow providing better support for pattern matching and exhaustiveness checking. In particular, when sealed classes are used in switch expressions, the compiler can check that all subclasses are handled.
In the following example, we take the above class Shape, and define a function area
that calculates the area of a shape. The function uses a switch expression to determine the area of the shape based on the type of the shape.
However, as the switch expression is not exhaustive, i.e. all subclasses of the sealed class Shape
are not handled, the compiler will give an error.
If you add handling for the Triangle
class, the error will disappear.
Enums
Programming languages also often offer ways to define named constants. In Dart, this is done using the enum
keyword, followed by the name of the enum and the list of constant values. The following example defines an enum called Day
that has the constants monday
, tuesday
, wednesday
, thursday
, friday
, saturday
, and sunday
.
enum Day {
monday,
tuesday,
wednesday,
thursday,
friday,
saturday,
sunday
}
By convention, enum values are written in Dart using lowerCamelCase
. Enums can be used as types, and the constants can be accessed using the dot notation. The following example demonstrates creating a variable of type Day
and assigning it the value Day.monday
.
void main() {
final day = Day.monday;
print(day);
}
Enums can also have properties and methods. The following example demonstrates defining an enum called Shape
that has the constants circle
, square
, and triangle
, and a “getter” property sides
that returns the number of sides of the shape. The getter uses a switch expression for determining the number of sides.
The following example demonstrates defining an enum called Direction
that has the constants north
, east
, south
, and west
, and a method turnRight
that returns the direction to the right of the current direction.