跳转至

14 Maps

There are no streets or lakes or countries on these maps. Maps in Dart are the data structure used to hold key-value pairs. They’re like hash maps and dictionaries in other languages.

If you’re not familiar with maps, think of them as collections of variables containing data. The key is the variable name, and the value is the data the variable holds. To find a particular value, give the map the name of the key mapped to that value.

In the image below, the cake is mapped to 500 calories and the donut is mapped to 150 calories. cake and donut are keys, whereas 500 and 150 are values.

img

Colons separate the key and value in each pair, and commas separate consecutive key-value pairs.

Creating a Map

Like List and Set, Map is a generic type, but Map takes two type parameters: one for the key and one for the value. You can create an empty map variable using Map and specifying the type for both the key and value:

final Map<String, int> emptyMap = {};

In this example, String is the type for the key, and int is the type for the value.

A slightly shorter way to do the same thing is to move the generic types to the right side:

final emptyMap = <String, int>{};

Notice that maps also use curly braces just as sets do. What do you think you’d get if you wrote this?

final emptySomething = {};

Is emptySomething a set or a map?

It turns out map literals came before set literals in Dart’s history, so Dart infers the empty braces to be a Map of <dynamic, dynamic>. In other words, the types of the key and value are both dynamic. If you want a set rather than a map, then you must be explicit:

final mySet = <String>{};

The single String type inside the angle brackets clarifies that this is a set and not a map.

Initializing a Map With Values

You can initialize a map with values by supplying the key-value pairs within the braces. This is known as a map literal.

Write the code below in main:

final inventory = {
  'cakes': 20,
  'pies': 14,
  'donuts': 37,
  'cookies': 141,
};

Observe the following points:

  • Dart uses type inference to recognize that inventory is a map of String to int, from bakery item to quantity in stock. All the keys are strings, and all the values are integers.
  • A colon separates the key and the value. For example, 'cakes' is the key for the value 20.
  • Commas separate multiple key-value pairs. The first key-value pair is 'cakes': 20, the second is 'pies': 14 and so on. The comma after the final pair, 'cookies': 141, is optional. However, including it ensures Dart will auto-format the code vertically.

The key doesn’t have to be a string. For example, here’s a map of int to String, from a digit to its English spelling:

final digitToWord = {
  1: 'one',
  2: 'two',
  3: 'three',
  4: 'four',
};

Print both of those:

print(inventory);
print(digitToWord);

You’ll see the output in horizontal format rather than the vertical format you had above:

{cakes: 20, pies: 14, donuts: 37, cookies: 141}
{1: one, 2: two, 3: three, 4: four}

Unique Keys

The keys of a map must be unique. A map like the following wouldn’t work:

final treasureMap = {
  'garbage': 'in the dumpster',
  'glasses': 'on your head',
  'gold': 'in the cave',
  'gold': 'under your mattress',
};

There are two keys named gold. How are you going to know where to look? You’re probably thinking, “Hey, it’s gold. I’ll just look both places.” If you wanted to set it up like that, you could map String to List:

final treasureMap = {
  'garbage': ['in the dumpster'],
  'glasses': ['on your head'],
  'gold': ['in the cave', 'under your mattress'],
};

Now, every key contains a list of items, but the keys themselves are unique.

Values don’t have that same restriction of being unique. This is fine:

final myHouse = {
  'bedroom': 'messy',
  'kitchen': 'messy',
  'living room': 'messy',
  'code': 'clean',
};

Operations on a Map

Dart makes it easy to access, add, remove, update and iterate over the key-value pairs in a map.

The examples that follow will continue to use the inventory map you made earlier:

final inventory = {
  'cakes': 20,
  'pies': 14,
  'donuts': 37,
  'cookies': 141,
};

Accessing Key-Value Pairs

You can look up the value of a particular key in a map using a subscript notation similar to that of lists. For maps, though, you use the key rather than an index.

Add the following line at the bottom of main:

final numberOfCakes = inventory['cakes'];

The key cakes is mapped to the integer 20, so print numberOfCakes to see 20.

A map will return null if the key doesn’t exist. Because of this, accessing an element from a map always gives a nullable value. In the example above, Dart infers numberOfCakes to be of type int?. If you want to use numberOfCakes, you must treat it as you would any other nullable value.

In this case, use the null-aware access operator to check if the number of cakes is even:

print(numberOfCakes?.isEven);

There were 20, so that’s true.

Adding Elements to a Map

You can add new elements to a map simply by assigning them to keys not yet in the map.

Add the following line below what you wrote previously:

inventory['brownies'] = 3;

Print inventory to see brownies and its value at the end of the map:

{cakes: 20, pies: 14, donuts: 37, cookies: 141, brownies: 3}

Updating an Element

Remember that the keys of a map are unique, so if you assign a value to a key that already exists, you overwrite the existing value.

inventory['cakes'] = 1;

Print inventory to confirm that cakes was 20 but now is 1:

{cakes: 1, pies: 14, donuts: 37, cookies: 141, brownies: 3}

Removing Elements From a Map

Use remove to delete elements from a map by key.

inventory.remove('cookies');

COOKIE! Om nom nom nom nom.

{cakes: 1, pies: 14, donuts: 37, brownies: 3}

No more cookies.

Accessing Properties

Maps have properties just as lists do. For example, the following properties indicate whether the map is empty:

inventory.isEmpty     // false
inventory.isNotEmpty  // true
inventory.length      // 4

You also can access the keys and values separately using the keys and valuesproperties.

print(inventory.keys);
print(inventory.values);

When you print that, you’ll see the following:

(cakes, pies, donuts, brownies)
(1, 14, 37, 3)

Checking for Key or Value Existence

To check whether a key is in a map, you can use the containsKey method:

print(inventory.containsKey('pies'));
// true

You can do the same for values using containsValue.

print(inventory.containsValue(42));
// false

Note

Adding to, updating and removing key-value pairs from a map are fast operations. Checking for a key with containsKey is fast, but checking for a value with containsValue is potentially slow because Dart has to iterate through possibly the entire collection of values. This only matters for large collections, though. You probably have nothing to worry about if you’re doing one-off value lookups on small maps. However, it’s still good to be aware of the performance characteristics of the underlying data structures.

Looping Over Elements of a Map

Unlike lists or sets, you can’t directly iterate over a map using a for-in loop:

for (var item in inventory) {
  print(inventory[item]);
}

This produces the following error:

The type 'Map<String, int>' used in the 'for' loop must implement Iterable.

Iterable is a type that knows how to move sequentially, or iterate, over its elements. List and Set both implement Iterable, but Map does not. You’ll learn more in Chapter 15, “Iterables”. You’ll also learn what “implement” means in Dart Apprentice: Beyond the Basics, Chapter 5, “Interfaces”.

There are a few solutions, though, for looping over a map. One solution is to use the Map.forEach method, which you’ll learn in Dart Apprentice: Beyond the Basics, Chapter 2, “Anonymous Functions”. Additionally, map’s keys and values properties are iterables, so you can loop over them. Here’s an example of iterating over the keys:

for (var key in inventory.keys) {
  print(inventory[key]);
}

You can also use entries to iterate over the elements of a map, which gives you both the keys and the values:

for (final entry in inventory.entries) {
  print('${entry.key} -> ${entry.value}');
}

Run that to see the following result:

cakes -> 1
pies -> 14
donuts -> 37
brownies -> 3

Before going on, test your knowledge of maps with the following exercise.

Exercise

  1. Create a map with the following keys: name, profession, country and city. For the values, add your information.
  2. You decide to move to Toronto, Canada. Programmatically update the values for country and city.
  3. Iterate over the map and print all the values.

Maps, Classes and JSON

In Chapter 8, “Classes”, you learned how JSON is used as a format to convert objects into strings. This is known as serialization, and it allows you to send objects over the network or save them in local storage. The JSON format is quite close to the structure of Dart maps, so you’ll often use maps as an intermediary data structure when converting between JSON and Dart objects.

The following sections will walk you through these conversion steps:

  • Object to map.
  • Map to JSON.
  • JSON to map.
  • Map to object.

You’ll start with the object-to-map conversion.

Converting an Object to a Map

Add the following Dart class to your project:

class User {
  const User({
    required this.id,
    required this.name,
    required this.emails,
  });

  final int id;
  final String name;
  final List<String> emails;
}

There are three properties, each of different types.

Now in main, create an object from that class like so:

final userObject = User(
  id: 1234,
  name: 'John',
  emails: [
    'john@example.com',
    'jhagemann@example.com',
  ],
);

If you were to write that information in the form of a map, it would look like so:

final userMap = {
  'id': 1234,
  'name': 'John',
  'emails': [
    'john@example.com',
    'jhagemann@example.com',
  ],
};

Notice how similar they are? The disadvantage of using a map is that the parameter names are strings. If you spell them wrong, you get a runtime error rather than the compile-time error the object parameter names would give you.

Many data classes such as User have a toJson method that returns a map like the one you saw above. Add the following method to User:

Map<String, dynamic> toJson() {
  return <String, dynamic>{
    'id': id,
    'name': name,
    'emails': emails,
  };
}

Here are some notes to pay attention to:

  • JSON itself is a string rather than a map, so technically, you should probably call this method toMap. But common practice is to call it toJson.
  • When serializing objects, you lose all type safety. So even though the key is String, the values are dynamic.

Use the following line to create a map from userObject:

final userMap = userObject.toJson();

Getting a map is just the intermediate step. Next, you’ll convert your map to JSON.

Converting a Map to a JSON String

It would be a chore to build a JSON string by hand. Thankfully, the dart:convert library already has the map-to-JSON converter built-in.

Add the import at the top of your project file:

import 'dart:convert';

Now, write the following code at the bottom of main to convert your map to a JSON string:

final userString = jsonEncode(userMap);
print(userString);

jsonEncode comes from the dart:convert library. You could also replace jsonEncode with json.encode. They’re equivalent. The map you pass in must have string keys, and the values must be simple types like String, int, double, null, bool, List and Map.

Run the code above, and you’ll see the JSON string printed to the console:

{"id":1234,"name":"John","emails":["john@example.com","jhagemann@example.com"]}

White space doesn’t matter in JSON. So, reformatted, that string would look like so:

{
  "id": 1234,
  "name": "John",
  "emails": [
    "john@example.com",
    "jhagemann@example.com"
  ]
}

Very similar to the Dart map, isn’t it? The difference is that the braces, brackets, colons, commas and quotation marks all are part of the string.

Now that you have a string, it’s in a form that allows you to easily save to a database or send across the internet.

How about the other way? Can you take a JSON string and convert it back to an object?

Converting a JSON String to a Map

In Dart Apprentice: Beyond the Basics, Chapter 12, “Futures”, you’ll get some firsthand experience retrieving an actual JSON string from a server on the internet. For now, though, you’ll practice with the hard-coded sample string below.

Write the following at the bottom of main:

final jsonString =
  '{"id":4321,"name":"Marcia","emails":["marcia@example.com"]}';

This JSON string contains an ID, user name and an email list with a single email address.

Now, add the following line below that:

final jsonMap = jsonDecode(jsonString);

This decodes the string and gives you a map. However, if you hover your cursor over jsonMap, you see the inferred type is dynamic. That’s all jsonDecode will tell you. It doesn’t know beforehand what types might be hiding in that string.

Because that’s the case, using dynamic for now is fine. But you might run into trouble if you forget that you’re using it. It’s easy to assume you’re working with a map or some other type when you might not be.

Preventing Hidden Dynamic Types

You can keep yourself from forgetting about dynamic by adding a setting to your analysis options file. Open analysis_options.yaml in the root of your project. Then, add the following lines at the bottom of the file and save your changes:

analyzer:
  strong-mode:
    implicit-dynamic: false

This tells Dart to always remind you to choose a type or explicitly write dynamic. That way, you won’t ever forget about it. This is a good idea to use in all your Dart and Flutter projects.

Explicitly Writing Dynamic

Back in your Dart file with the main function, the compiler is complaining at you now:

Missing variable type for 'jsonMap'.
Try adding an explicit type or removing implicit-dynamic from your analysis options file.

To make that error go away, replace final in the jsonDecode line above with dynamic, like so:

dynamic jsonMap = jsonDecode(jsonString);

Now, you can clearly see that you’re working with a dynamic value.

Handling Errors

You can do a little error checking on the type using the is keyword:

if (jsonMap is Map<String, dynamic>) {
  print("You've got a map!");
} else {
  print('Your JSON must have been in the wrong format.');
}

Run that, and you’ll see:

You've got a map!

In Dart Apprentice: Beyond the Basics, Chapter 10, “Error Handling”, you’ll learn even more sophisticated ways to deal with errors.

Converting a Map to an Object

You could, at this point, extract all the data from your map and pass it into the Userconstructor. However, that’s error-prone if you have to create many objects. Earlier, you added a toJson method. Now you’ll add a fromJson constructor to go the other way.

Add the following factory constructor to your User class:

factory User.fromJson(Map<String, dynamic> jsonMap) {
  // 1
  dynamic id = jsonMap['id'];
  if (id is! int) id = 0;
  // 2
  dynamic name = jsonMap['name'];
  if (name is! String) name = '';
  // 3
  dynamic maybeEmails = jsonMap['emails'];
  final emails = <String>[];
  if (maybeEmails is List) {
    for (dynamic email in maybeEmails) {
      if (email is String) emails.add(email);
    }
  }
  // 4
  return User(
    id: id,
    name: name,
    emails: emails,
  );
}

The numbered comments above correspond with the following notes:

  1. First, you attempt to extract the user ID from the map. You don’t have type information, so if id turns out to be something besides an int, you just give id a default value of 0.
  2. If name isn’t a String, give it a default value of an empty string. This will keep your app from crashing if it gets junk data. However, rather than creating a user without a name, you might want to throw an error instead so your app can cancel the creation of this user or show an error message. As mentioned earlier, Dart Apprentice: Beyond the Basics will talk more about this.
  3. First, check that jsonMap['emails'] is actually a List. If it is, make sure each item in the list is a String and then add it to your list of known emails.
  4. Return a new User object with the values from the map.

Make your object printable by also adding toString to your User class:

@override
String toString() {
  return 'User(id: $id, name: $name, emails: $emails)';
}

Now, you can easily create an object in main like so:

final userMarcia = User.fromJson(jsonMap);
print(userMarcia);

Run the code to check that you’ve correctly set the values:

User(id: 4321, name: Marcia, emails: [marcia@example.com])

You’ve come full circle. You’re back to having an object again!

Challenges

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

Challenge 1: Counting on You

Write a function that takes a paragraph of text as a parameter. Count the frequency of each character. Return this data as a map where the key is the character and the value is the frequency count.

Challenge 2: To JSON and Back

Create an object from the following class:

class Widget {
  Widget(this.width, this.height);
  final double width;
  final double height;
}

Then:

  1. Add a toJson method to Widget. It should return a map.
  2. Use toJson to convert your object to a map.
  3. Convert the map to a JSON string.
  4. Convert the JSON string back to a map.
  5. Add a fromJson factory constructor to Widget.
  6. Use fromJson to convert the map back to a widget object.

Key Points

  • Maps store a collection of key-value pairs.
  • Using a key to access its value always returns a nullable type.
  • If you use a for-in loop with a map, you need to iterate over the keys or values.
  • You can convert a Dart object to a map by making the property names from the object the keys of the map.
  • JSON objects use similar syntax to Dart maps.
  • The dart:convert library allows you to convert between JSON and Dart maps.

Where to Go From Here?

As you continue to create Dart applications, you’ll find that you’re writing methods and constructors like toJson and fromJson repeatedly. If you find that tiring, consider using a code generation package from pub.dev, such as freezed or json_serializable. You only need to write the properties for the class, and these packages will generate the rest.