跳转至

8 Classes

So far in this book, you’ve used built-in types such as int, String and bool. You’ve also seen one way to make custom types using enum. In this chapter, you’ll learn a more flexible way to create types by using classes.

Note

Because there’s quite a bit to learn about classes and object-oriented programming (OOP) in Dart, you’ll come back to the subject again in the following chapters as well as in the book Dart Apprentice: Beyond the Basics.

Classes are like architectural blueprints that tell the system how to make an object, where an object is the actual data stored in the computer’s memory. If a class is the blueprint, you could say the object is like the house the blueprint represents. For example, the String class describes its data as a collection of UTF-16 code units, but a String object is something concrete like 'Hello, Dart!'.

All values in Dart are objects that are built from a class. This includes the values of basic types like int, double and bool. That’s different from other languages like Java, where basic types are primitive. For example, if you have x = 10 in Java, the value of x is 10 itself. But Dart doesn’t have primitive types. Even for a simple int, the value is an object that wraps the integer. You’ll learn more about this concept later.

Classes are a core component of object-oriented programming. They’re used to combine data and functions inside a single structure.

img

The functions exist to transform the data. Functions inside a class are known as methods, whereas constructors are special methods you use to create objects from the class. You’ll learn about properties and methods in this chapter and about constructors in Chapter 9, “Constructors”.

It’s time to get your hands dirty. Working with classes is far more instructive than reading about them!

Defining a Class

To start creating types, you’ll make a simple User class with id and nameproperties. This is just the kind of class you’re highly likely to create in the future for an app that requires users to log in.

Write the following simple class at the top level of your Dart file. Your class should be outside of the main function, either above or below it.

class User {
  int id = 0;
  String name = '';
}

This creates a class named User. It has two properties: id is an int with a default value of 0, and name is a String with the default value of an empty string.

The member variables of a class are generally called fields. But when the fields are public and visible to the outside world, you can also call them properties. The Encapsulation section below will show you how to simultaneously use public properties and private fields.

Note

Depending on the situation, null may be a better default than 0 or ''. But because nullable types and null safety won’t be fully covered until Chapter 11, “Nullability”, this chapter will simply use reasonable defaults for properties.

Creating an Object From a Class

As mentioned above, the value you create from a class is called an object. Another name for an object is instance, so creating an object is sometimes called instantiating a class.

Writing the User class as you did above simply created the blueprint; a User object doesn’t exist yet. You can create the object by calling the class name as you would call a function. Add the following line inside the main function:

final user = User();

This creates an instance of your User class and stores that instance, or object, in user. Notice the empty parentheses after User. It looks like you’re calling a function without any parameters. In fact, you’re calling a type of function called a constructor method. You’ll learn a lot more about them in the next chapter. Right now, simply understand that using your class in this way creates an instance of your class.

The Optional Keyword New

Before version 2.0 of Dart came out, you had to use the new keyword to create an object from a class. At that time, creating a new instance of a class would have looked like this:

final user = new User();

This still works, but the new keyword is completely optional now, so it’s better just to leave it off. Why clutter your code with unnecessary words, right?

You’ll still come across new from time to time in the documentation or legacy code, but at least now it won’t confuse you. If you meet it, just delete it.

Assigning Values to Properties

Now that you have an instance of User stored in user, you can assign new values to this object’s properties using dot notation. To access the name property, type userdot name and then give it a value:

user.name = 'Ray';

Now, set the ID similarly:

user.id = 42;

Your code should look like the following:

void main() {
  final user = User();
  user.name = 'Ray';
  user.id = 42;
}

class User {
  int id = 0;
  String name = '';
}

You’ll notice that you have both a function and a class together. Dart allows you to put multiple classes, top-level functions and even top-level variables together in the same file. Their order in the file isn’t important. User is located below main here, but if you put it above main, that’s fine as well.

You’ve defined a User data type with a class, created an object from it and assigned values to its parameters. Run the code now, though, and you won’t see anything special happen. The following section will show you how to display data from an object.

Printing an Object

You can print any object in Dart. But if you try to print user now, you don’t get quite what you hoped for. Add the following line at the bottom of the main function and run the code:

print(user);

Here’s what you get:

Instance of 'User'

Hmm, you were likely expecting something about Ray and the ID. What gives?

Except for Null, all classes in Dart derive from Object, which has a toStringmethod. In this case, your object doesn’t tell Dart how to write its internal data when you call toString on it, so Dart gives you this generic, default output instead. But you can override the Object class’s version of toString by writing your implementation of toString and thus customize how your object will print out.

Add the following method to the User class:

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

Words that start with @ are called annotations. Including them is optional and doesn’t change how the code executes. But annotations do give the compiler more information so it can help you at compile time. Here, the @override annotation tells you and the compiler that toString is a method in Object that you want to override with your customized version. So if you accidentally wrote the toString method signature incorrectly, the compiler would warn you about it because of the @overrideannotation.

Because methods have access to the class properties, you simply use that data to output a more meaningful message when someone prints your object. Run the code now, and you’ll see the following result:

User(id: 42, name: Ray)

That’s far more useful!

Note

Your User class only has a single method right now, but in classes with many methods, most programmers put the toString method at or near the bottom of the class instead of burying it in the middle somewhere. As you continue to add to User, keep toString at the bottom. Following conventions like this makes navigating and reading the code easier.

Adding Methods

Now that you’ve learned to override methods, you’ll move on and add your methods to the User class. But before you do, there’s a little background information that you should know.

Understanding Object Serialization

Organizing related data into a class is super useful, especially when you want to pass that data around as a unit within your app. One disadvantage, though, shows up when you’re saving the object or sending it over the network. Files, databases and networks only know how to handle simple data types, such as numbers and strings. They don’t know how to handle anything more complex, like your User data type.

Serialization is the process of converting a complex data object into a form you can store or transmit, usually a string. Once you’ve serialized the object, it’s easy to save that data or transfer it across the network because everything from your app to the network and beyond knows how to deal with strings. Later, when you want to read that data back in, you can do so by way of deserialization, which is simply the process of converting a string back into an object of your data type.

You didn’t realize it, but you serialized your User object in the toString method above. The code you wrote was good enough to get the job done, but you didn’t follow any standardized format. You simply wrote it in a way that looked nice to the human eye. If you gave that string to someone else, though, they might have some difficulty understanding how to convert it back into a User object (deserialize it).

It turns out that serialization and deserialization are such common tasks that people have devised standardized formats for serializing data. One of the most common is called JSON: JavaScript Object Notation. Despite the name, it’s used far and wide outside the world of JavaScript.

JSON isn’t difficult to learn, but this chapter will only show you enough JSON to serialize your User object into a more portable format. Check the Where to Go From Here?section at the end of the chapter to find out where you can learn more about this format.

Adding a JSON Serialization Method

You’re going to add another method to your class now that will convert a User object to JSON format. It’ll be like what you did in toString.

Add the following method to the User class, putting it above the toString method:

String toJson() {
  return '{"id":$id,"name":"$name"}';
}

Here are a few things to note:

  • Because this is your custom method and you’re not overriding a method that belongs to another class, you don’t add the @override annotation.
  • In Dart naming conventions, acronyms are treated as words. Thus, toJson is a better name than toJSON.
  • There’s nothing magic about serialization in this case. You simply used string interpolation to insert the property values in the correct locations in the JSON formatted string.
  • In JSON, curly braces surround objects, commas separate properties, colons separate property names from property values, and double quotes surround strings. If a string needs to include a double-quote inside itself, you escape it with a backslash like so: \"
  • JSON is similar to a Dart data type called Map. Dart even has built-in functions in the dart:convert library to serialize and deserialize JSON maps. And that’s actually what most people use to serialize objects. But you haven’t read Chapter 14, “Maps”, so this example will be low-tech. You’ll see a little preview of Map in the fromJson example in Chapter 9, “Constructors”.

To test your new function, add the following line to the bottom of the main method:

print(user.toJson());

This code calls the custom toJson method on your user object using dot notation. The dot goes between the object name and method name, just like you saw earlier for accessing a property name.

Run the code, and you’ll see the following:

{"id":42,"name":"Ray"}

It’s very similar to what toString gave you, but this time it’s in standard JSON format, so a computer on the other side of the world could easily convert that back into a Dart object.

Cascade Notation

When you created your User object above, you set its parameters like so:

final user = User();
user.name = 'Ray';
user.id = 42;

But Dart offers a cascade operator (..) that allows you to chain together multiple assignments on the same object without having to repeat the object name. The following code is equivalent:

final user = User()
  ..name = 'Ray'
  ..id = 42;

Note that the semicolon appears only on the last line.

Cascade notation isn’t strictly necessary, but it makes your code a little tidier when you have to assign a long list of properties or repeatedly call a method that modifies your object.

Objects as References

Objects act as references to the instances of the class in memory. That means if you assign one object to another, the other object simply holds a reference to the same object in memory — not a new instance.

So if you have a class like this:

class MyClass {
  var myProperty = 1;
}

And you instantiate it like so:

final myObject = MyClass();
final anotherObject = myObject;

Then myObject and anotherObject both reference the same place in memory. Changing myProperty in either object will affect the other because they both reference the same instance:

print(myObject.myProperty);    // 1
anotherObject.myProperty = 2;
print(myObject.myProperty);    // 2

As you can see, changing the property’s value on anotherObject also changed it in myObject because they are just two names for the same object.

Note

If you want to make an actual copy of the class — not just a copy of its reference in memory but a whole new object with a deep copy of all the data it contains — you’ll need to implement that mechanism by creating a method in your class that builds up a whole new object. Some call this method copyWith and allow the user to change selected properties while making the copy.

If that still doesn’t make sense, you can look forward to sitting down by the fire and listening to the story of The House on Wenderlich Way in Chapter 12, “Lists”, which will make this all clear or at least help you see it from a different perspective.

For now, though, there are a few more improvements you can make to the User class.

Encapsulation

One of the core tenets of object-oriented programming is known as encapsulation. This is the principle of hiding the internal data and logic in a class from the outside world. Doing so has a few benefits:

  • The class controls its data, including who sees it and how it’s modified.
  • Isolated control means the logic is easier to reason about.
  • Hidden data means better privacy.
  • Encapsulation prevents unrelated classes from reaching in and modifying data, which can cause hard-to-find bugs.
  • You can change the internal variable names and logic without breaking things in the outside world.

So those are the benefits. How do you accomplish encapsulation in Dart?

Hiding the Internals

You can make a variable private in Dart by prefixing the name with an underscore.

Create the following class in your project:

class Password {
  String _plainText = 'pass123';
}

The name _value begins with an underscore, so it’s private. That means there’s no way to access the user ID and name outside of the class, which makes your object kind of useless. You can solve this problem by adding a getter.

Note

The above description isn’t exactly accurate. In Dart, private means library private, not class private. A Dart library generally corresponds to a single file. That means other classes and functions in the same file have access to variable names that begin with an underscore. But these same variables are invisible from other libraries. You’ll see this in action in Chapter 9, “Constructors”.

Getters

A getter is a special method that returns the value of a private field variable. It’s the public face of a private variable. If you’ve done any Java or C++ programming, you’ve probably seen getter methods with names like getColor or getWidth. Following this naming conversion, your Dart class would look like so:

class Password {
  String _plainText = 'pass123';

  String getPlainText() {
    return _plainText;
  }
}

You would access the password in main like so:

final myPassword = Password();
final text = myPassword.getPlainText();

But Dart discourages prefixing method names with get and instead has special syntax for getter properties.

A Dart getter uses the get keyword and returns a value. This gives you, as the class author, some control over how people access properties instead of allowing raw and unfettered access.

Replace your Password class with the updated form:

class Password {
  String _plainText = 'pass123';

  String get plainText => _plainText;
}

The get keyword provides a public-facing property name; in this case, plainText. The get method returns a value when you call the getter using its name. The getter method here is simply returning the _plainText field value.

Now that the property is exposed, you can use it like so:

final myPassword = Password();
final text = myPassword.plainText;
print(text); // pass123

Note that no () parentheses are needed after plainText. From the outside, this getter looks just like a normal property that you made with a field variable. The difference is that you can’t set a value using a getter.

Getters Don’t Set

Try adding the following line at the bottom of main:

myPassword.plainText = '123456';

Dart complains with an error:

There isn’t a setter named 'plainText' in class 'Password'.
Try correcting the name to reference an existing setter or declare the setter.

You’ll need to add a setter to change the internal value of _plainText. For now, just delete the line with the error in it.

Calculated Properties

You can also create getters that aren’t backed by a dedicated field value but are calculated when called.

For example, you might not want to expose the plain text value of a password. Add the following getter to Password:

String get obfuscated {
  final length = _plainText.length;
  return '*' * length;
}

Here are some notes:

  • There’s no internal variable named obfuscated or _obfuscated. Rather, the return value of obfuscated is calculated when necessary.
  • Getters can use arrow syntax or brace syntax just like regular methods. The example here uses brace syntax with a return statement because you’re using more than a single line of code to calculate the return value.
  • Multiplying a string by a number repeats the string that many times. In this case, you get a string of asterisks the same length as the password.

Replace the contents of main with the following code:

final myPassword = Password();
final text = myPassword.obfuscated;
print(text);

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

*******

This password is top secret!

Setters

Use a setter if you want to change the internal data in a class, Dart has a special setkeyword for this to go along with get.

Add the following setter to your Password class:

set plainText(String text) => _plainText = text;

A setter starts with the set keyword. The set method takes a parameter, which you can use to set some value. In this case, you’re setting the internal _plainText field.

You can now assign and retrieve the value of _plainText like this:

final myPassword = Password();
myPassword.plainText = r'Pa$$vv0Rd';
final text = myPassword.plainText;
print(text); // Pa$$vv0Rd

The second line sets the internal _plainText field, and the third line gets it.

You can see how this could give you extra control over what’s assigned to your properties. For instance, you could use the setter to validate a good password.

Using Setters for Data Validation

Replace the plainText setter that you wrote above with the following version:

set plainText(String text) {
  if (text.length < 6) {
    print('Passwords must have 6 or more characters!');
    return;
  }
  _plainText = text;
}

If the user tries to set the value with a short password, there will be a warning and the internal field value won’t change.

Test this out in main:

final shortPassword = Password();
shortPassword.plainText = 'aaa';
final result = shortPassword.plainText;
print(result);

Run this, and you’ll see the following output:

Passwords must have 6 or more characters!
pass123

The password wasn’t updated to aaa.

No Need to Overuse Getters And Setters

You don’t always need to use getters and setters explicitly. If all you’re doing is shadowing some internal field variable, you’re better off just using a public variable.

For example, if you’ve written a class like this:

class Email {
  String _value = '';

  String get value => _value;
  set value(String value) => _value = value;
}

You might as well just simplify that to the following form:

class Email {
  String value = '';
}

Dart implicitly generates the needed getters and setters for you. That’s quite a bit more readable and still works the same as if you had written your getters and setters:

final email = Email();
email.value = 'ray@example.com';
final emailString = email.value;

That’s the beauty of how Dart handles class properties. You can change the internal implementation without the external world being any the wiser.

If you only want a getter but not a setter, just make the property final and set it in the constructor. You’ll learn how to do that in Chapter 9, “Constructors”.

Challenges

Before moving on, here’s a challenge to test your knowledge of classes. It’s best if you try to solve it yourself, but a solution is available with the supplementary materials for this book if you get stuck.

Challenge 1: Rectangles

  • Create a class named Rectangle with properties for _width and _height.
  • Add getters named width and height.
  • Add setters for these properties that ensure you can’t give negative values.
  • Add a getter for a calculated property named area that returns the area of the rectangle.

Key Points

  • Classes package data and functions inside a single structure.
  • Variables in a class are called fields, and public fields or getter methods are called properties.
  • Functions in a class are called methods.
  • You can customize how an object is printed by overriding the toString method.
  • Classes have getters and setters, which you can customize without affecting how the object is used.

Where to Go From Here?

This chapter touched briefly on JSON as a standard way to serialize objects. You’ll certainly be using JSON in the future, so you can visit json.org to learn more about this format and why it’s gained so much traction as a standard.