Mastering Dart Classes: A Comprehensive Guide

Mastering Dart Classes: A Comprehensive Guide

Introduction

A class serves as a template for organizing and encapsulating information or logic, allowing you to define the core logic in one central location for reuse. This fundamental template of logic is referred to as a "class," while the individual implementations of this logic are known as "instances". To illustrate, envision the creation of a class named "Person" and the instantiation of that class, akin to constructing a replica of the class and naming it "person". Any alterations made to the class blueprint, such as "person", will have a corresponding impact on its instances, like "person."

Constructors

A constructor is a special function, typically named after the class itself, which plays a crucial role in producing an instance of the class. When you create an instance of the class, you actively invoke the class's constructor.

class Person{
    final String name;
    final int age;
    Person({
        required String name;
        required int age;
    }): name = name;
        age = age;
}

The provided code example establishes a Person class with two final properties: name and age. The constructor, named "Person" as well, receives name and age parameters. It directly assigns these parameters to their respective properties using initializer lists. Essentially, when you create a Person object, you must provide values for name and age, which subsequently defines the object's properties. This approach guarantees that the constructor plays an active role in creating "Person" class instances with the intended property values.

The above code example can be rewritten as shown below:

class Person {
    final String name;
    final int age;

    Person({
        required this.name,
        required this.age,
    });
}

In this revised code, the constructor uses Dart's newer syntax, which combines property declaration and assignment by using the "this" keyword with the "required" keyword for parameter names, eliminating the need for initializer lists. This makes the code more readable and efficient.

To create instances of the Person class, you can do the following:

void main() {
    final person1 = Person(name: "James", age: 28);
    final person2 = Person(name: "mike", age: 23);
}

In this example, we have created instances of the Person class named person1 and person2 also providing the name and age parameters when initializing them.

To access the properties of the person1 and person2 objects, you can use the dot notation to reference their properties. you can access these properties as follows:

void main() {
    final person1 = Person(name: "James", age: 28);
    final person2 = Person(name: "Mike", age: 23);

    print("Person 1: Name - ${person1.name}, Age - ${person1.age}");
    print("Person 2: Name - ${person2.name}, Age - ${person2.age}");
}

In the code above, we use person1.name to access the name property of the person1 object and person1.age to access the age property. Similarly, we access the properties of the person2 object. The output will display the name and age of both person1 and person2.

Named constructor

You can define named constructors within a class. Named constructors enable you to create objects with different sets of parameters. You declare them using the syntax ClassName.constructorName.

Suppose you wish to consistently create a Person class instance named "Peter" with an age of 23. This can be accomplished by defining a constructor with an initializer list, as illustrated in the code snippet below:

class Person {
  final String name;
  final int age;

  const Person.peter() : name = "Peter", age = 23;
}

To create an object using this Person class constructor, you can employ the following approach:

final person3 = Person.peter();

In the code above, the named constructor peter, is used to create a Person object with the name "Peter" and an age of 23, which is subsequently assigned to the variable person3. This construction ensures consistent instantiation of a Person with the desired attributes.

In contrast to the previous example, where the name and age were hard-coded, we now aim to create a constructor that can accept variable name and age values as parameters. This can be accomplished with the following code:

class Person {
    final String name;
    final int age;

    // Named constructor
    Person.unknown(
    String name, 
    int age
    ) : name = name,
        age = age;
}

The provided code achieves the same goal as the one in an earlier example, with the only difference being the use of a named constructor instead of a regular constructor. It also illustrates the concept that constructors, like regular functions, can accept parameters.

Another approach is to create a dual-purpose constructor that can take optional name and age parameters. If provided, these values will initialize the Person instance; otherwise, default name and age values will be used. Here is an illustrative code example:

class Person {
    final String name;
    final int age;

    const Person.other({
        String? name, 
        int? age,
    }) : name = name ?? "User1", 
         age = age ?? 20;
}

In the code example above, when initializing the Person instance, default values of "User1" for the name and 20 for the age are automatically applied if the name and/or age parameters are omitted. This flexible constructor is valuable when creating user profiles because complete information, such as the user's name or age, may not always be available, enabling you to create a Person object with the available data and apply default values when necessary.

Subclassing

In object-oriented programming (OOP), inheritance is a fundamental concept that enables the creation of a new class, known as a subclass, by inheriting the attributes and behaviors of an existing class, referred to as a superclass.

To illustrate this concept, consider the real-life analogy of a father and son. Just as in real life, where a son often exhibits a striking resemblance to his father, the son can be viewed as a subclass of his father, inheriting certain traits. This analogy closely aligns with the essence of subclassing in programming, where you create a class and its subclasses, enabling the derived classes to inherit specific properties and functionalities from the parent class or blueprint.

Let's analyze a Dart code example:

class Person {
  final int age;

  const Person(
    this.age
  );
}

void main() {
  const aPerson = Person(23);
  print(aPerson.toString());
}

In the code above, we have a Person class with an age property, and we create an instance of this class called aPerson with an age of 23. Within the main function, we print the result of calling the toString() method on the aPerson object.

It's worth noting that the Person class doesn't explicitly define a toString() method. However, in Dart, every class implicitly inherits from the Object class, which includes a default toString() method.

So, when we call aPerson.toString(), Dart utilizes the default toString() method provided by the Object class to convert the aPerson object into a string representation. This is the reason you don't encounter an error, and the code effectively prints a string representation of the aPerson object.

A subclass can override properties or methods from a base class. For instance, if we want to override the toString() method from the Object base class, we can accomplish this, as shown in the following example:

void main() {
  const aPerson = Person(23);
  print(aPerson.toString());
}

class Person {
  final int age;

  const Person(
    this.age
  );

  @override
  String toString() {
    return '$runtimeType with age $age';
  }
}

When you run the code above, you'll get the following output:

Person with age 23

In Dart, it's important to mention that when you define a class, every instance of that class has a runtime type that corresponds to the class itself. You can access this runtime type using the runtimeType property.

The extends keyword

In Dart and many other object-oriented programming languages, the "extends" keyword is used to establish an inheritance relationship between classes. When a class extends another class, it inherits both the properties (fields) and behaviors (methods) of the parent class.

Consider a Vehicle class with a "noOfWheels" property. Since every car is a subtype of a vehicle and has a specific number of wheels, when creating a Car class, there's no need to create a new "noOfWheels" property in the Car class. Instead, we can have the Car class inherit properties from the Vehicle class, like this:

class Vehicle {
  final int noOfWheels;
  Vehicle(this.noOfWheels);
}

class Car extends Vehicle {
  final String carModel;
  Car(this.carModel) : super(4);
}

In this example, a car is essentially a vehicle with four wheels. To establish this connection, we utilize the super keyword within the Car class's constructor. This instructs Dart to call the superclass constructor (i.e., the Vehicle constructor) and pass it the value 4 for the noOfWheels property. This approach enables us to tailor the default implementation to represent the exact number of wheels that cars have.

Alternatively, you can create the Car class as follows:

class Car extends Vehicle {
  final String carModel;
  Car(super.noOfWheels, this.carModel);
}

With this approach, you are essentially specifying that anyone creating an instance of the Car class must provide a value for the noOfWheels property. This value will then be passed to the "noOfWheels" property of the Vehicle class during the construction of a Car object. This action invokes the constructor of the Vehicle class, ensuring that the car has the specified number of wheels. This method offers a direct way to set the number of wheels for a Car instance while maintaining the inheritance relationship.

Methods

Methods are fundamental concepts in object-oriented programming, and their purpose is to define and encapsulate behaviors within a class or object. In essence, methods are functions that are specific to a particular class.

Consider the following Dart code as an example:

void main() {
  final myCar = Car();
  myCar.accelerate(speed: 25);
}

class Car {
  int speed = 0;

  void accelerate({required int speed}) {
    this.speed = speed;
    print('Accelerating at $speed km/h'); //Accelerating at 25 km/h
  }
}

In this code, there is a Car class with an accelerate method, enabling the car to modify its speed. Within the main function, we instantiate a Car class and employ the accelerate method to set the car's speed to 25 km/h. Methods play a pivotal role in defining the behavior and functionality of objects within a class.

Getters

Getters play a crucial role in facilitating access to private or encapsulated data members (fields) of an object from outside the class. Their primary purpose is to retrieve specific values, providing an efficient means to access information stored within an object.

Let's examine a code example involving a Car class that includes a private attribute and a getter method:

class Car {
  String _brand; // Private attribute

  Car(this._brand);

  // Getter for the 'brand' attribute
  String get brand {
    return _brand;
  }
}

void main() {
  Car myCar = Car('Mercedes-Benz');
  print('My car is a ${myCar.brand}');
}

In this example, we have a Car class with a private attribute _brand. To access this private attribute from outside the class, we've created a getBrand getter method. In the main function, we create a Car object and utilize the getter to obtain and print the brand of the car. This getter method provides a controlled and more readable way to access the private attribute.

Setters

Setters are valuable tools for modifying or updating the values of an object's private or encapsulated data members (fields) from outside the class. They offer a way to set or assign new values to these attributes.

Considering the code example below:

class Car {
  String _brand;
  Car(this._brand);

  String get brand {
    return _brand;
  }

  // Setter for the 'brand' attribute
  set brand(String newBrand) {
    _brand = newBrand;
  }
}

void main() {
  Car myCar = Car('Mercedes-Benz');

  // Accessing the 'brand' attribute using the getter
  print('My car is a ${myCar.brand}');

  // Modifying the 'brand' attribute using the setter
  myCar.brand = 'BMW';
  print('My car is now a ${myCar.brand}');
}

In this example, we have included a setter method, setBrand, to modify the car's brand from outside the class. Inside the main function, we create a Car object, utilize the getter to retrieve and print the car's brand, and then use the setter to change the car's brand. Finally, we print the updated value. Setters offer a controlled means to alter private attributes.

Static Properties and Methods

In the classes we've created thus far, every instance of the class had access to the class's properties and methods. Any modifications made to an instance didn't impact the class as a whole. Nonetheless, there are scenarios where you might require a property within the class that can only be accessed at the class level, rendering it inaccessible to individual instances of the class.

void main() {
  Person.name = 'James';
  print(Person.name);
  Person.name = 'Mike';
  print(Person.name);
}

class Person {
  static String name = '';
}

In the code example above, there's a Person class with a static property, name. Static properties are accessible at the class level, as demonstrated by setting and printing the name property in the main function. It's important to note that attempting to access the name property through an instance of the Person class will result in an error. This is because static properties cannot be accessed through instances.

To define a static method, you use the static keyword before the method declaration, as shown below:

class Person {
  static String methodName() {
    return 'Hello';
  }
}

Static properties and methods in a class can be used in various scenarios where you need class-level data or behavior shared among all instances of the class.

Factory Constructors

The factory keyword in Dart is used when defining constructors that don't create new instances of the current class. Instead, these constructors are often utilized to produce instances of sub-classes or to provide alternative ways of creating objects.

In specific situations, factory constructors are utilized to return instances of sub-classes rather than instances of the class to which they belong.

Here's a code example that illustrates the use of a factory constructor to return a Car instance, which is a sub-class of Vehicle class:

class Vehicle {
  const Vehicle();
  factory Vehicle.car() => const Car();
}

class Car extends Vehicle {
  const Car();
}

In this example, the Vehicle class incorporates a factory constructor, car(), which generates an instance of the Car class. This illustrates how factory constructors can be employed to create instances of sub-classes.

Abstract Classes

An abstract class serves as a template for other classes, requiring its sub-classes to implement a common interface or a set of methods.Abstract classes cannot be directly instantiated. They are particularly useful when you need to define a shared structure or behavior but do not intend to create instances of the abstract class itself.

Here's a code example that illustrates the concept of an abstract class and its implementation in a subclass:

abstract class Vehicle {
  final String vehicleType;

  Vehicle({required this.vehicleType});

  void accelerate();
}

class Car implements Vehicle {
  @override
  final String vehicleType = 'Car';

  @override
  void accelerate() {
    print('Accelerating the Car');
  }
}

In this code example, the abstract class Vehicle defines a shared interface and includes an accelerate method. The 'Car' class implements the Vehicle interface and provides its own implementation for the accelerate method. This demonstrates how an abstract class serves as a blueprint to establish common behavior while enabling specific implementations in its subclasses.

In Dart, when defining a method in an abstract class, you don't need to enclose the method body in curly braces. Instead, you can use a semicolon after the method signature within the abstract class. This is because subclasses are meant to override abstract methods, and there's no need for a default implementation.

Extending an abstract class in Dart is possible, but there's a notable difference compared to extending a regular class. When you extend an abstract class, you must override all the methods declared in the abstract class. This implies that the subclass is obligated to provide concrete implementations for all the abstract methods specified in the parent abstract class. Failure to override any of these methods will lead to a compilation error.

Mixins

Mixins provide a way to efficiently reuse a class's code across multiple class hierarchies, avoiding the need for traditional inheritance. They enable you to seamlessly integrate a class's properties and behaviors into other classes without the necessity of creating entirely new class hierarchies.

To define a mixin, you use the mixin keyword followed by a class that offers a set of methods and properties that can be incorporated into other classes. To apply a mixin in a class, you use the with keyword followed by the name of the mixin class. This effectively integrates the behavior of the mixin class into the class where it is used.

Here's a code example to illustrate the use of mixins:

void main() {
  final Person person = Person(name: 'Mike');
  person.jump();
}

mixin JumpAction {
  int jumpHeight = 2;
}

class Person with JumpAction {
  final String name;

  Person({required this.name});

  void jump() {
    print('$name is jumping at a height of $jumpHeight meters');
  }
}

In this code, we define a mixin called JumpAction that provides a jumpHeight property. The Person class then uses the with keyword to apply the JumpAction mixin, incorporating the behavior of JumpAction. This grants Person instances access to the jumpHeight property provided by the mixin.

You can use multiple mixins in a single class by separating them with commas, allowing you to combine the behavior and properties from multiple mixin classes into a single class.

Here is an example illustrating the use of multiple mixins in a class:

mixin JumpAction {
  int jumpHeight = 2;
}

mixin RunAction {
  final bool isRunning = false;
}

class Person with JumpAction, RunAction {
  final String name;

  Person({required this.name});
}

In the code example, the Person class includes both the JumpAction and RunAction mixins, thus inheriting properties from them.

Using the with keyword with a mixin class shares similarities with using the extends keyword with a regular (non-abstract) class. However, there is a fundamental difference: while extends implies a parent-child relationship, with does not establish a parent-child relationship.

The mixin keyword, when combined with the on keyword, offers the ability to impose constraints on the types of classes that can make use of the mixin. Using the on keyword allows you to limit the application of the mixin exclusively to classes that fulfill certain criteria, such as implementing a specific interface or inheriting from a particular superclass.

Here's a Dart code example demonstrating the use of the mixin keyword in conjunction with the on keyword to specify constraints:

class Animal {
  void action(){
  }
}

mixin JumpAction on Animal {
  int jumpHeight = 2;
}

class Cat extends Animal with JumpAction {
  @override
  void action() {
    print('The cat is jumping at a height of $jumpHeight meters');
  }
}

In this example, the JumpAction mixin is constrained by the on Animal clause, indicating that it can only be applied to classes that inherit from the Animal class. This use of the on keyword ensures that the mixin is only available to classes meeting the specified criteria, enhancing code clarity and safety.

Conclusion

This article provides a comprehensive exploration of essential object-oriented programming concepts in Dart, with a focus on classes, constructors, and inheritance. It emphasizes the crucial roles these elements play in organizing code and creating objects. The article also covers various types of constructors, including named and flexible constructors with optional parameters, offering flexibility in object creation. It delves into methods, getters, setters, and static properties and methods, highlighting their significance in defining class behaviors and managing data.

Furthermore, the article introduces factory constructors, abstract classes, and mixins, demonstrating their versatile applications and advantages for designing reusable and efficient code. In particular, mixins are discussed in the context of integrating behavior across class hierarchies. The article also touches on using the "on" keyword to set constraints on mixins, ensuring code clarity and safety.

In summary, this article equips developers with a comprehensive understanding of object-oriented programming in Dart, providing the knowledge and tools needed to create well-structured and scalable software in this object-oriented paradigm.