跳转至

9 Enhanced Enums

Back in Dart Apprentice: Fundamentals, you learned about basic enums. They’re a way to give names to a fixed number of options. An example would be the days of the week. Under the hood, each day has an index: monday is 0, tuesday is 1 and so on through sunday is 6. That’s why they’re called enums. They’re enumerated values with names.

Using enums in this way has always been useful, but with Dart 2.17, they got even better. You can treat the new enhanced enums like classes. And that’s what they are. Dart enums are subclasses of the Enum class. That means you can do many of the same things to enums that you would do with a class, including adding properties and methods, implementing interfaces and using mixins and generics.

Sound good? Get ready to enhance your enum skills then!

Reviewing the Basics

To get started, review what you already know about enums.

What to Use Enums For

Enums are great when you have a fixed number of options you want to represent. Here are some examples of that:

  • Traffic light states (green, yellow, red).
  • Days of the week (Monday, Tuesday, …).
  • Months of the year (January, February, …).
  • Audio playback states (playing, paused, …).
  • Weather types (sunny, cloudy, …).

All these topics are more or less constant. People aren’t going to be adding an eighth day of the week any time soon. Similarly, there are only a finite number of audio playback states. You could argue there are an indeterminate number of weather types, but if you’ve thought through your weather app carefully, you probably have a limited set that you need to show icons for.

On the other hand, when a category has frequent changes or an unlimited number of possibilities, this isn’t a great choice for enums. Here are some examples of things you probably shouldn’t represent with an enum:

  • Users
  • Songs
  • URLs

If you add another song to your app, you’ll probably have to refactor other parts of your code. For example, if you’re handling the enum cases with switch statements, you have to update all the switch statements. So rather than enums, you’d be better off representing the data types listed above with classes you can store in a list. Then, whenever you add a new user or a new song, just add a new item to the list.

Advantages of Using Enums

In the past, people often wrote logic like this:

const int GREEN = 0;
const int YELLOW = 1;
const int RED = 2;

void printMessage(int lightColor) {
  switch (lightColor) {
    case GREEN:
      print('Go!');
      break;
    case YELLOW:
      print('Slow down!');
      break;
    case RED:
      print('Stop!');
      break;
    default:
      print('Unrecognized option');
  }
}

However, there were a few problems with this kind of logic:

  • The function takes any integer, so if you had defined int VOLUME = 2 somewhere else, there would be nothing to stop you from passing in VOLUME to the function, even though this function has nothing to do with volume.
  • The compiler doesn’t know there are only three possible options, so it can’t warn you if you provide a value besides 0, 1 or 2. This requires you to handle error cases with default.
  • Sometimes people used similar logic but with strings instead of integers. For example, if (lightColor == 'green'). With that method, it was easy to accidentally misspell values, such as writing geen instead of green.

Dart enums solve all those problems:

  • Each enum has its own namespace, so there’s no way to accidentally pass in Audio.volume when a function only accepts TrafficLight enum values.
  • The Dart compiler is smart enough to know how many values an enum has. That means you don’t need to use a default in a switch statement as long as you’re already handling all the cases. Dart will also warn you if you aren’t handling an enum case.
  • The compiler tells you immediately if you misspell an enum value.

All in all, these features of enums make them a much better option than using integer or string constants as option markers.

Coding a Basic Enum

Can you write an enum for the colors of a traffic light?

Open a Dart project and add the following enum outside of main:

enum TrafficLight {
  green,
  yellow,
  red,
}

You use the enum keyword followed by the enum name in upper camel case. Curly braces enclose the comma-separated enum values. Adding a comma after the last item is optional but ensures that Dart will format the list vertically.

In main, use your TrafficLight enum like so:

final color = TrafficLight.green;
switch (color) {
  case TrafficLight.green:
    print('Go!');
    break;
  case TrafficLight.yellow:
    print('Slow down!');
    break;
  case TrafficLight.red:
    print('Stop!');
    break;
}

Dart recognizes that you’re handling all the enum values, so no default case is necessary.

Run the code above, and you’ll see Go! printed to the console.

That was a basic enum. Enhanced enums will allow you to simplify that code a lot. Keep reading to find out how.

Treating Enums Like Classes

Enums are just classes, and enum values are instances of the class. This means that you can apply much of your other knowledge about classes.

Adding Constructors and Properties

Just as classes have constructors and properties, so do enums.

Replace the TrafficLight enum you wrote earlier with the enhanced version:

enum TrafficLight {
  green('Go!'),
  yellow('Slow down!'),
  red('Stop!');

  const TrafficLight(this.message);
  final String message;
}

Here’s what has changed:

  • The enum has a const constructor, which it uses to set the final message field in the class. Enum constructors are always const.
  • green, yellow and red are the only instances of the TrafficLight enum class. They each call the constructor and set the value of message for their instance.
  • The last enum case, which is red in this example, ends with a semicolon. Commas still separate the other cases.

It’s also permissible to keep the trailing comma, but you would still need to add a semicolon:

// alternate formatting
green('Go!'),
yellow('Slow down!'),
red('Stop!'),           // trailing comma
;                       // semicolon

The only advantage here is that you’re explicitly telling the Dart formatter to display the enum list vertically rather than horizontally. Dart seems to do that anyway in this case, even without the trailing comma, so there’s no need to add it. Carry on without making this change.

Now, your previous switch statement is no longer necessary. Replace the code in main with the following:

final color = TrafficLight.green;
print(color.message);

Your enum has a message parameter, which allows you to access the message directly. No need for switch statements. That’s much better, isn’t it?

Run your code, and you’ll see the same message as before:

Go!

Operator Overloading

This is a good opportunity to teach you an aspect of classes you might not know about yet. The topic is operator overloading.

As you recall, operators are symbols like the following:

  • Arithmetic operators: + -, *, /, ~/, %
  • Equality and relational operators: ==, !=, >, <, >=, <=
  • Assignment operators: =, +=, -=, *=, \=, …
  • Logical operators: !, &&, ||
  • Bitwise and shift operators: &, |, ^, ~, >>, <<, >>>

These operators all have meanings in certain contexts. For example, when you use the + operator with integers, Dart adds them together:

print(3 + 2); // 5

When the context is strings, + has a different meaning:

print('a' + 'b'); // ab

In this case, Dart concatenates the two strings to produce ab.

However, what would it mean if you tried to add users, as in user1 + user2? In this context, Dart wouldn’t know what to do because the + operator isn’t defined for adding User classes.

You do have the opportunity to give your own meaning to operators when the context makes sense, though. This is called operator overloading. Many, though not all, of the operators you saw above support overloading.

The following example will show how to overload an operator in a normal class. After that, you’ll see a second example where you can apply operator overloading to enums.

Overloading an Operator in a Class

Create the following Point class, which has x-y coordinates:

class Point {
  const Point(this.x, this.y);
  final double x;
  final double y;

  @override
  String toString() => '($x, $y)';
}

This class can represent points on a two-dimensional coordinate system where points are in the form (x, y), such as (1, 4) or (3, 2). The image below shows these on a graph where y is increasing in the downward direction, the usual orientation for rendering graphics:

img

Implement these two points in code by writing the following in main:

const pointA = Point(1, 4);
const pointB = Point(3, 2);

In some situations, you might want to add two points together. You’d accomplish that by first adding the x-coordinates of the two points and then the y-coordinates. The following image shows the result:

img

Dart doesn’t know how to add two points, but you can tell Dart how to do it by overloading the + operator in your Point class.

Add the following code inside Point:

Point operator +(Point other) {
  return Point(x + other.x, y + other.y);
}

Here are a few points, hehe :], to note:

  • Use the operator keyword when you want to overload an operator.
  • Treat the operator as a method name. Because it is the method name. The +method is invoked on the first point: the point that comes before the +. The other point is the point that comes after the +.
  • The return line creates a new point with the sum of the x-coordinates and the sum of the y-coordinates.

Now you can add points!

Add the following lines to the bottom of main:

final pointC = pointA + pointB;
print(pointC);

Run that, and you’ll see the following result:

(4.0, 6.0)

Overloading an Operator in an Enum

Because enums are classes, they also support operator overloading.

Take the following enum for the days of the week as an example:

enum Day {
  monday,
  tuesday,
  wednesday,
  thursday,
  friday,
  saturday,
  sunday,
}

It might not make sense to add Monday plus Tuesday, but it sort of makes sense to say monday + 2. That would be two days later, right? Wednesday.

Replace the comma after sunday with a semicolon and add the + operator overload.

Here’s the complete code:

enum Day {
  monday,
  tuesday,
  wednesday,
  thursday,
  friday,
  saturday,
  sunday;

  Day operator +(int days) {
    // 1
    final numberOfItems = Day.values.length;
    // 2
    final index = (this.index + days) % numberOfItems;
    // 3
    return Day.values[index];
  }
}

The numbered comments have the following notes:

  1. values is a list of all the enum values, so length gives you the total number of values, which is 7 because there are seven days in a week.
  2. The index is the enumerated value of each enum value. monday is 0, tuesday is 1, wednesday is 2 and so on. Because this.index is an integer, you can add days to it. The % modulo operator divides the result by 7 and gives the remainder. This makes the new index never go out of bounds, no matter how large days is. It will start over at the beginning of the list. sunday + 1 is monday because (6 + 1) % 7 is 0.
  3. Convert your newly calculated index back to an enum, and you’re good to go.

To test it out, run the following code in main:

var day = Day.monday;

day = day + 2;
print(day.name); // wednesday

day += 4;
print(day.name); // sunday

day++;
print(day.name); // monday

Not only does the + operator work, you get += and ++ for free!

Adding Methods

You can also add methods to an enum just as you would to a normal class. Technically, operator overloading is already adding a method, but this section will provide an additional example.

Add the following getter method to your Day enum:

Day get next {
  return this + 1;
}

Because you already implemented support for the + operator, this method returns the next day by adding 1 to whatever value this day is.

Try it out in main like so:

final restDay = Day.saturday;
print(restDay.next);

Run that, and you’ll see Day.sunday printed in the console.

Implementing Interfaces

Say you have the following interface that you use to serialize objects for storage in a database:

abstract class Serializable {
  String serialize();
}

As a reminder, “serializing” something just means to convert an object to a basic data type, most commonly a string.

Make an enum named Weather that implements the interface like so:

enum Weather implements Serializable {
  sunny,
  cloudy,
  rainy;

  @override
  String serialize() => name;
}

serialize directly returns the enum name, such as 'sunny' or 'cloudy'. The built-in name property is already a string.

Optionally, you can also add a deserialize method to your enum to go the other direction:

static Weather deserialize(String value) {
  return values.firstWhere(
    (element) => element.name == value,
    orElse: () => Weather.sunny,
  );
}

In contrast to the higher-order method where that you learned about in Chapter 2, “Anonymous Functions”, firstWhere returns only a single value. By comparing the input value to the enum name, you convert the string back to an enum. If the string value doesn’t exist, orElse will give you a default of Weather.sunny.

Use the methods in main like so:

final weather = Weather.cloudy;

String serialized = weather.serialize();
print(serialized);

Weather deserialized = Weather.deserialize(serialized);
print(deserialized);

Run this code to see the serialized string and the deserialized Weather object:

cloudy
Weather.cloudy

This example was merely to show you the syntax of implementing an interface. You could use these same methods without the interface, and it would all still work the same. The interface is only useful if some other part of your app requires Serializable objects.

Note

Once you’ve serialized an enum, you can never change it again. Well, you can, but you do so at your peril. Say you’ve saved a bunch of enum values as strings in the database or sent them across the network. At that point, your enums have been released to the wild. You can’t get them back because they’re stored on user devices and far-away servers. If you change your enum in the next app update and then try to deserialize the old enum strings, you’ll get mismatches and unexpected behavior. That’s another reason you don’t want to make enums out of things that change frequently.

Adding Mixins

If you have a bunch of different enums where you’re repeating the same logic, you can simplify your code by adding a mixin.

Here’s an example:

enum Fruit with Describer {
  cherry,
  peach,
  banana,
}

enum Vegetable with Describer {
  carrot,
  broccoli,
  spinach,
}

mixin Describer on Enum {
  void describe() {
    print('This $runtimeType is a $name.');
  }
}

Now, Fruit and Vegetable share the describe method. Using the on keyword in the mixin gave you access to the name property of the Enum class.

Test your mixin in main like so:

final fruit = Fruit.banana;
final vegi = Vegetable.broccoli;

fruit.describe();
vegi.describe();

Run that, and you’ll see:

This Fruit is a banana.
This Vegetable is a broccoli.

OK, you English grammarians, so it shouldn’t be “a broccoli” but just “broccoli”. The author hadn’t had dinner yet, and that’s the best example he could think of.

Using Generics

Normally, all the values in an enum will be of the same type. For example, in the Sizeenum below, the value of each enum item is an int:

enum Size {
  small(1),
  medium(5),
  large(10);

  const Size(this.value);
  final int value;
}

However, you might want to store different types for each enum value in certain situations. Take the following example:

enum Default {
  font,
  size,
  weight,
}

Say you have some default values you want to associate with each enum value. The default font is “roboto”, a String; the default size is 17.0, a double; and the default weight is 400, an int. Each enum value is a different instance of Enum. And when different instances use different types for their values, you need generics to handle them.

Replace the Default enum above with one that uses generics:

enum Default<T extends Object> {
  font<String>('roboto'),
  size<double>(17.0),
  weight<int>(400);

  const Default(this.value);
  final T value;
}

Object is the nearest common parent type of String, double and int, so the generic T type extends Object. This allows value to take any of those types. Remember that each enum value (font, size and weight) is an instance of the enum class. The type in angle brackets tells the constructor the type for that instance.

Write the following in main:

String defaultFont = Default.font.value;
double defaultSize = Default.size.value;
int defaultWeight = Default.weight.value;

Although there’s only one value property in your enum, it resolves to a different type depending on the selected enum instance.

That wraps it up for this chapter. In the next chapter, you’ll learn how to handle errors.

Challenges

Before moving on, here are some challenges to test your knowledge of enhanced enums. It’s best if you try to solve them yourself, but solutions are available with the supplementary materials for this book if you get stuck.

Challenge 1: Better Days Ahead

In this chapter, you wrote a Day enum with the seven days of the week.

  1. Override the - operator so you can subtract integers from enum values.
  2. When you print the name of your Day enum, it prints the days of the week in all lowercase. It’s standard to use lower camel case for enum values, but it would be nice to use uppercase for the display name. For example, Monday instead of monday. Add a displayName property to Day for that.

Challenge 2: Not Found, 404

Create an enum for HTTP response status codes. The enum should have properties for the code and the meaning. For example, 404 and 'Not Found'. If you aren’t familiar with the HTTP codes, look them up online. You don’t need to cover every possible code, just a few of the common ones.

Key Points

  • Dart enums are subclasses of Enum.
  • Enums are good for representing a fixed number of options.
  • Prefer enums over strings or integers as option markers.
  • Enhanced enums support constructors, properties, methods, operator overloading, interfaces, mixins and generics.
  • Enums must have const constructors.
  • The enum values are instances of the enum class.
  • Operator overloading allows classes to give their own definitions to some operators.