Dart 3.1 and a retrospective on functional programming in Dart 3

Great article from Marya Belanger about new functionality in dart 3 with a retrospective on previous OOP capabilities. Keep in mind, despite the “read in 10 minutes” tag, this is medium difficulty material with a bunch of new Dart terms. And if you are not familiar with the new things in Dart 3, such as pattern matching, switch expressions, sealed classes, then take your reading to a favorable split-screen environment (IDE|dartpad || article) to test your own hypotheses 😉

Enjoy reading!


Marya Belanger

Marya (mar-like mars, -yuh like yuck 🙂 ) is a Dart technical writer with a hell of a passion for improving the documentation on dart.dev.

Pattern matching and exhaustive switches combine to create functional data models that fit seamlessly into Dart's object-oriented core.

Difference in Dart 3 refactoring using functional styles in Dart's internal codebase

Difference in Dart 3 refactoring using functional styles in Dart's internal codebase

Today (Aug 16, 2023) we are releasing Dart 3.1, our first stable release since our major release. Dart 3.0 in May. Dart 3.1 contains a few minor updates and a few API adjustments to further utilize the class modifiers introduced in 3.0 (which you can read more about in changelog). We're mostly spending time on new roadmap items that we hope will reach beta and stable state in upcoming releases. Stay tuned for more on this in the future!

So instead of a traditional release article, we'll look at some of the major features of Dart 3 and how they can completely change, and in some cases significantly improve, the way you write and structure your Dart code.


How do you model the data?

Object-oriented (OO) and functional languages ​​differ in many ways, but it can be argued that the defining characteristic that separates them is the way each paradigm models data. In particular, we are talking about modeling various variations of related data and operations on them.

But the question “how should I model this data?” usually doesn't give us much thought when we start a new project. We tend to default to choosing the data modeling paradigm that is specific to the language we are using, as opposed to the opposite – choosing a language based on the model that makes the most sense for our data.

If you use an OO language, you model data using a class hierarchy and operations on subtypes. If you are using some functional languages, then the equivalent of the class hierarchy model is algebraic data type modelin which the equivalent of working with subtypes is to switch between them using pattern matching.

A simplified comparison of the object-oriented class hierarchy model and the functional algebraic data type model

A simplified comparison of the object-oriented class hierarchy model and the functional algebraic data type model

Dart is an object-oriented language, but features have been continually added to it over time, allowing for a more multi-paradigm approach to data modeling. Most recently, Dart 3 added pattern matching, new functionality for switch And sealed types. These innovations allow Dart to implement algebraic data types, potentially allowing you to write code in a functional style while still making the most of Dart's object-oriented core.

Multi-paradigm languages ​​like Dart give you the tools and flexibility to design from single-line expressions to entire class hierarchies. You may want to consider which model makes the most sense for your project or even just your personal preference. To help you make the best decision, in this article we'll take a quick look at the structure and strengths of each paradigm individually, and then teach you how to use Dart 3's new features to refactor some of the classic object-oriented designs that benefit most from being written in functional style.

Object-oriented approach

When you have operations that are specific to different data types, the standard approach to organization in OO languages ​​is to create a method in a base class and a set of subclasses that override the base class to define their own unique behavior. Each subclass has its data and operations in one place within its declaration.

Let's take this (high-level pseudocode) recipe modeling example. It makes sense to have recipe objects, associated ingredients, and steps along with the recipe. The base recipe class will likely contain some functions for cooking methods, which each recipe overrides to suit its unique requirements:

abstract class Recipe {
  final int time;
  final int temp;
  final ingredients = [];
  
  void bake();
}

class Cake extends Recipe {
  Cake() : super({
    time: 40,
    temp: 325,
    ingredients: [Flour, Eggs, Milk];
  });

  @override  
  void bake() => time * temp;
}

class Cookies extends Recipe {
  Cookies() : super({
    time: 25,
    temp: 350,
    ingredients: [Flour, Butter, Sugar];
  });

  @override
  void bake() {
    (time / 2) * temp;
    rotate();
    (time / 2) * (temp - 15);
  }
}

Class hierarchies with instance methods make it easy to add new subclasses without affecting existing code. This is great for some areas like Flutter where you have countless widgets that all extend the class Widget. Each widget can be uniquely extended and override any required behavior right within its definition. To you definitely you don't need to know how each widget subtype defines its methods to add specialized behavior to your own widgets.

Functional approach (algebraic data types)

Functional style architecture can be seen as the reverse of OO architecture. Instead of storing all the code for one type in one place (OO instance methods in subclass declarations), you store all the code for one operation in one place (functional switch type mapping to define behavior).

This raises the question, when does it make sense to know how each subtype in the hierarchy defines an operation? This can be caused by several reasons:

  • It's easier to add, maintain, and understand behaviors of the same operation across different types when they're all close together in the code.

  • When you can't modify subclasses yourself, but want to define new behavior specific to each of them.

  • When variations in operation behavior for different types are more related to each other than to the types they operate on.

Sometimes it's obvious, but more often than not it's just a change in perspective. Consider the recipe example again. From the perspective of, say, an oven manual, it would make much more sense to group the baking instructions in one place for each recipe:

abstract class Recipe {
  // only fields
}

class Cake extends Recipe {
  // only fields
}

class Cookies extends Recipe {
  // only fields
}

void bake(Recipe recipe) {
  time: recipe.time;
  temp: recipe.temp;
  
  if recipe is Cake: {
    time * temp;
  }
  if recipe is Cookies: {
    (time / 2) * temp;
    rotate();
    (time / 2) * (temp - 15);
  }
}

In this example, the program structure is focused on the baking operation bake. Whatever types you operate on bakethese are simply different possible results of the same function; bake does not depend on the types it works with.

This algebraic data type model (called “algebraic” after mathematical set theory). This model is the basic organizational model of functional languages, just as class hierarchies are the basis of object-oriented languages. Algebraic data types separate behavior from data by grouping behavior for all types into operations.

And now you can implement algebraic data types in Dart 3!

Modeling object-oriented algebraic data types

Functional languages ​​usually implement algebraic data types by pattern matching across cases in amount typeto assign a behavior to each option. Dart 3 achieves the same result with pattern matching in switch cases and takes advantage of the fact that object-oriented subtyping already naturally models sum types. This allows us to truly realize multi-paradigm algebraic data typesusing objects that fit easily into Dart.

In the following sections, we'll show you how to develop algebraic data type models in Dart, as well as examples of similar functionality that existed before Dart 3.

  • We'll first explain how to group operation options based on types by switching to object templates.

  • Then we'll take a step back and look at how to design the subclasses themselves using the new modifier sealedto make sure the switch defines the behavior for all possible object subtypes.

Grouping behavior by type

Individual parts of the Dart language (such as operators, classes, and literals) have their own definitions in the class hierarchy, but they are all subject to operations by multiple systems (such as the parser, formatter, and compiler). Imagine how confusing a language implementation would be if every function applied to every element of the language had to be defined in the declarations of those elements! It will look something like this:

class SomeLanguageElement extends LanguageElement {
  // every annotation in the language
  // every parser operation in the language
  // every formatter operation in the language
  // etc...
}

// Repeat x1000000 for everything that makes up a programming language!

For this reason, Dart's internal code already naturally leans toward a functional approach, separating functions from type definitions. Take the library annotation_verifier in the Dart analyzer. It contains functions that define the behavior of annotations (for example, @overrideor @deprecated) depending on what part of the code the annotation is attached to (for example, how @override affects the class, not the field).

But classifying behavior into types is not as simple as deciding to classify the behavior as a separate type. The standard way to define behavior by type is using a chain of statements if-else, which you often see in the annotation verifier. Take the following test function, written without using any Dart 3 features. It tests the behavior recently created annotations @visibleOutsideTemplate which forgoes the cascading effects of another annotation, @visibleForTemplate:

void _checkVisibleOutsideTemplate(Annotation node) {
    var grandparent = node.parent.parent;
    if (grandparent is ClassDeclaration &&
        grandparent.declaredElement != null) {
      for (final annotation in grandparent.declaredElement!.metadata) {
        if (annotation.isVisibleForTemplate) return;
      }
    } else if (grandparent is EnumDeclaration &&
        grandparent.declaredElement != null) {
      for (final annotation in grandparent.declaredElement!.metadata) {
        if (annotation.isVisibleForTemplate) return;
      }
    } else if (grandparent is MixinDeclaration &&
        grandparent.declaredElement != null) {
      for (final annotation in grandparent.declaredElement!.metadata) {
        if (annotation.isVisibleForTemplate) return;
      }
    }
    // ...
  }

The function uses complex chains if-elsechecking whether the parent annotation is a declaration of a certain type (ClassDeclaration, EnumDeclaration or MixinDeclaration), and then defining its behavior depending on the type.

In Dart 3, you can use object templates in switch cases to significantly refactor this structure into a more declarative style, making it shorter and easier to read. And the original author is that's what I did! 16 lines of operator chains if-else boil down to 7 lines of switch statement:

void _checkVisibleOutsideTemplate(Annotation node) {
    var grandparent = node.parent.parent;
    switch (grandparent) {
      case ClassDeclaration(declaredElement: InterfaceElement(:var metadata)):
      case EnumDeclaration(declaredElement: InterfaceElement(:var metadata)):
      case MixinDeclaration(declaredElement: InterfaceElement(:var metadata)):
        for (final annotation in metadata) {
          if (annotation.isVisibleForTemplate) return;
        }
    }

Every case here is object templatewhich maps to a static type grandparent. Instead of talking

if (object is Type && object.property != null),

in each case it is checked whether the object's template matches the template Type(propertyOfType). Additionally, when an object matches an object pattern, it implicitly requires that it not be nullso there's no need to explicitly check for null!

Object templates can also contain nested variable templateswhich allow you to extract (or destructure) property values ​​from the object in the same line of code that is being matched. Syntax (:var metadata) simply means “map and declare a new variable with the same name as this getter.” So variable metadata falls within the scope of the last for loop. Pretty succinct!

Please note that the cycle for now general for each case. Property declaredElement each type is actually a subtype of another type, InterfaceElement (or classElement, enumElementor mixinElement). So, in the pre-Dart 3 chain if-else ourmetadata in every sentence if iterated separately to ensure that final annotation will be safe for every possible type metadata.

The reorganized structure now uses deeply nested object templates for each case to convey metadata to your supertype, InterfaceElement. This makes one overall loop foriterating types metadatasafe for all cases.

Annotated depiction of syntax for deeply nested objects and variable templates

Annotated depiction of syntax for deeply nested objects and variable templates

The move to object templates is important for Dart's implementation of algebraic data types because it allows for succinct subtyping and value destructuring. A nice side effect is the concurrent guarantees that can be provided with a single line of code. Let us repeat that each template in this case simultaneously checks:

  • Object is one of the types ClassDeclaration, EnumDeclaration or MixinDeclaration.

  • An object has a property declaredElement.

  • declaredElement has the property metadata.

  • metadatahas type InterfaceElement.

  • None of the objects or properties in question are null.

This is a great example of how Dart 3 has carefully implemented patterns that take into account many of the nuances of OO languages ​​and truly make object-oriented algebraic data types a realistic design option in Dart.

Type checks over object templates are great for separating behavior from types. But it misses one feature of OO subtyping, where the compiler tells you if you declare a new subtype but don't define behavior for one of the abstract methods of its supertype. How can Dart's algebraic data type model implement the same safety guarantees if we are no longer dealing with instance methods in type declarations? Answer – completeness check.

Completeness check

Implementations of algebraic data types in functional languages ​​use enumerable sum types, which means that the compiler is always aware of all possible variations of the type being switched. The compiler may tell you that your switch is missing a register, which means there is a chance that some values ​​will pass through the switch without being addressed.

It is called completeness check (I also call this “checking for exhaustiveness” – approx. per.). Technically, this has always existed in Dart for enumerated types such as enums and booleans. These types have a set of possible values ​​that cannot change, and if you switch between them, the compiler knows when you missed one and warns you about it. Usage default is another type of pseudo-completeness. Because the value default matches all cases not explicitly taken into account, it causes the compiler to treat the switch as exhaustive without knowing whether all potential types are actually taken into account.

As mentioned, we wanted to use subtypes instead of sum types for Dart's version of algebraic data type modeling. But since classes in Dart can be extended from any library, the compiler will not be able to exhaustively list the subtypes of a class because it cannot know whether any subclasses are declared in external libraries.

To work around this issue and complete the implementation of algebraic data types in Dart, we added in Dart 3 sealed class modifier. Class marked as sealedcannot be extended or implemented from any library other than its own (file containing its definition). This ensures that the compiler is always aware of all possible subtypes, making them fully enumerable.

Here's an example of an actual refactoring that was made to the Dart SDK as part of the 3.1 release: “sealing” FileSystemEvent (link) so that its subtypes can be exhaustive. Get ready, refactoring is hard…

- final class FileSystemEvent {
+ sealed class FileSystemEvent {

Just kidding, it wasn't difficult at all! However, it should be noted that sealing an existing class hierarchy is a breaking change. Code targeting older versions of Dart will not be able to implement or extend this class, so always check dependencies and warn users who may be using subtypes of your classes elsewhere.

Sealing FileSystemEvent allows you to comprehensively switch the events produced FileSystemEntity.watchwhich correspond to subtypes FileSystemEvent. The typical thing is to listen to this stream of events and use operator chains if-else to determine actions depending on the type of events occurring.

But sealing the base class allows you to do more than just switch template objects, as in the example with _checkVisibleOutsideTemplate in the previous section. This also ensures that you account for all possible values ​​that could arise for a given type, without having to use the default case:

(bool contentChanged, bool removed) _fileListener(FileSystemEntity directory) async {

  await for (final event in directory.watch()) {
    return switch (event) {
      FileSystemModifyEvent(contentChanged: final changed) => (changed, false),
      FileSystemCreateEvent() => (false, false),
      FileSystemDeleteEvent() => (false, true),
      FileSystemMoveEvent() => (false, false),
    };
  }
}

If a new subtype is ever added that extends FileSystemEventFor example FileSystemSyncEventthe compiler will know about it because it can only be added to the same libraryas FileSystemEvent. Because the class hierarchy is sealed, the compiler requires that any switch over its instances be exhaustive, and will give an errorto warn the user (who wrote the switch, not the library's creator) about unhandled cases:

The type 'FileSystemEvent' is not exhaustively matched by the switch cases since it doesn't match 'FileSystemSyncEvent'

The combination of sealed classes and switches through object templates allows Dart to implement a full-fledged object-oriented algebraic data type in the program architecture.

Bonus Features

The above comprehensive switch example includes even more Dart 3 functionality than those that make it easier to work with algebraic data types.

note that switch is to the right of the return statement return functions _fileListener – this is new switch expression in Dart 3. The general emphasis on expressions and functions is a key element of functional languages. Dart 3 introduced switch expressions, which can output a value and jump to any location where the expression is valid.

And in general, what does it return? _fileListener in the previous example? This record (also called Recording – approx. Per.), another new feature of Dart 3, also related to functional programming. Records allow you to return multiple values ​​of different types from a function, extending the functionality of functions in Dart and moving away from the reliance on custom classes (which would be the only way to return multiple values ​​of different types without losing their types in the process).

Conclusion

You can model algebraic data types in Dart like this:

  • Write a function that includes an instance of the sealed class and its subtypes,

  • And determine the deviation in the behavior of each subtype in each switch case.

Object pattern matching allows you to concisely combine all operations, and completeness checking ensures that the compiler will warn you if you're missing behavior definitions for any types. And it's all built on top of the object-oriented classes that Dart already uses.

The best part is that you don't have to choose an object-oriented or functional style; the two paradigms fit together, and you can use the style that best suits your case.

You can make existing class hierarchies more functional with minor changes, and even mix the use of instance methods with algebraic data types in the same class hierarchy. Whether it makes sense to tightly couple behavior to a type or to group behavior for different types into a single function, you can use whatever style makes the most sense.

We hope this introduction gets you interested in functional programming and trying out new Dart 3 features. Who knows, we might see the first one soon “full-featured” Dart program from one of you!

Materials

To learn more about functional programming in Dart and beyond, visit these resources:


Material translated Ruble – author of a small postzhik, creator of applications and packages, commentator on SO and just a fan of Flutter.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *