dart-lang / language

Design of the Dart language
Other
2.68k stars 205 forks source link

Static classes #3075

Open AlexVegner opened 1 year ago

AlexVegner commented 1 year ago

Record is a great feature, it brings us close to minimalistic data classes. It will be nice to have ability to create statically typed class records with with full potential of records. In additional to inline classes (https://github.com/dart-lang/language/issues/2727) it can provide a lot of new instruments.

Preliminary sintaxis:

static class Point on ({int x, int y}) {}
final p = Point(x: 0, y: 0);
final (:x, :y) = u2;

It will bring all existing class functinnality + additional methods and static methods

Should NOT ALLOW:

Should ALLOW:

Sample for records:

static class User on ({String? firstName, String? lastName}) {
  static User? fromJson(Map<String, dynamic> json) {
    return switch (json) {
      {
        'firstName': String? firstName,
        'lastName': String? lastName,
      } =>
        (firstName: firstName, lastName: lastName),
      _ => null,
    };
  }

  Map<String, dynamic> toJson() {
    final (:firstName, :lastName) = this;
    return {'firstName': firstName, 'lastName': lastName};
  }
}

static class Employee on ({String? firstName, String? lastName}) {}

final record = (firstName: 'A', lastName: 'V');
final user = record as User;
final u2 = User.fromJson('{"firstName": "A", "lastName": "V"}'); 
final (:firstName, :lastName) = u2;
final e1 = record as Employee;
final e2 = user as Employee;
// Feature request: make a copy and mutate with spread operator support 
final e3 = (...e1, firstName: 'D'); // Auto cast to Employee
final e4 = (...record, firstName: 'D') as Employee;
final e5 = (...user, firstName: 'D') as Employee;

assert(user != record); // different type
assert(e1 == e2); // same type ans data after cast
assert(e2 != user); // different type
assert(e3 == e4);

Also it will allow to add static methods to abstract classes

// app_colors.dart
import 'package:flutter/material.dart' as material;

static class AppColors on material.Colors {
   static const Color myBusinessColor = Color(0x012345678);
}

And add functionality to extarnal pachage classes

// package:openapi/src/models/user.dart
class User {
  final String firstName;
  final String lastName;
}
import 'package:openapi/openapi.dart' as openapi;
static class User on openapi.User {
  Map<String, dynamic> toJson
() => '{"firstName": firstName, "lastName": value.lastName};

  static User? fromJson(Map<String, dynamic> json) {
    if (json case {'firstName': String firstName, 'lastName: String lastName}) {
      return Category((firstName: firstName, lastName: lastName));
    }
    return null;
  }
}

final user = User.fromJson('{"firstName": "A", "lastName": "V"}'); 
lrhn commented 1 year ago

I'm not seeing a lot of difference between this an inline classes, #2727, at least if we allow the inline classes to implement or extend their representation type (which I hope we will, and plan on pushing for).

The one difference I can see is that you want to override equality, which is not something an inline class can do, because it's entirely static, and not a real wrapper object. Since == is very often invoked as an instance method on an object with a generic type variable as type, there'd need to be some kind of runtime overhead in order to replace the object's own equals method.

AlexVegner commented 1 year ago

@lrhn Thanks for response, hope inline classes will provide elegant solution for static record type.

eernstg commented 1 year ago

I think it's interesting to take a look at the example here expressed using inline classes:

import 'dart:convert';

// ignore_for_file: unused_local_variable

typedef UserRecord = ({String? firstName, String? lastName});

inline class User {
  final UserRecord value;

  User({String? firstName, String? lastName})
      : value = (firstName: firstName, lastName: lastName);
  User.fromRecord(this.value);

  String? get firstName => value.firstName;

  String? get lastName => value.lastName;

  Map<String, dynamic> toJson() => {
        'firstName': value.firstName,
        'lastName': value.lastName,
      };

  static User? fromJson(Map<String, dynamic> json) => switch (json) {
        {
          'firstName': String? firstName,
          'lastName': String? lastName,
        } =>
          User(firstName: firstName, lastName: lastName),
        _ => null,
      };
}

inline class Employee implements User {
  final UserRecord value;

  Employee({String? firstName, String? lastName})
      : value = (firstName: firstName, lastName: lastName);
  Employee.fromRecord(this.value);

  /* These  members should be available due to `implements User`, but that
     has not yet been implemented in the analyzer. So we can copy-paste them
     if we need to get the analyzer to accept the program:

     String? get firstName => value.firstName;
     String? get lastName => value.lastName;

     Map<String, dynamic> toJson() => {
       'firstName': value.firstName,
       'lastName': value.lastName,
     };
  */
}

void main() {
  final record = (firstName: 'A', lastName: 'V');
  final user = User.fromRecord(record);
  final json = jsonDecode('{"firstName": "A", "lastName": "V"}');
  final u2 = User.fromJson(json)!;
  final User(:firstName, :lastName) = u2;
  final e1 = Employee.fromRecord(record);
  final e2 = Employee.fromRecord(user.value);

  // Knowing that these types are inline types, we can cast.
  final e1worksButIsNotRecommended = record as Employee;
  final e2worksButIsNotRecommended = user as Employee;

  // No spread operator or `copyWith` methods, but today we can do this:
  final e3 = Employee(firstName: 'D', lastName: e1.lastName);
  final e4 = Employee(firstName: 'D', lastName: record.lastName);
  final e5 = Employee(firstName: 'D', lastName: user.lastName);

  assert(user == record); // Different type, same record.
  assert(e1 == e2); // Same type, same record.
  assert(e2 == user); // Different type, same record.
  assert(e3 == e4); // Same type, equal records.
}

The big difference is that an inline class relies on a purely static distinction between the representation object (denoted by the unique final instance variable that every inline class must have) and the value whose type is the inline class: It's the same object at run time.

So we get == in the final assert statements when the underlying record is equal, and record equality is based on per-field equality (so you can create a new record with equal field values, and they will be equal).

This illustrates that there is some manual work associated with an inline class whose representation is a record (for instance, we need to write String? get firstName etc.), but it can be done, and the outcome is a type which has structural equality, statically resolved member implementations, and which allows compilers to box/unbox the object whenever that's good for the performance.

eernstg commented 1 year ago

Adding static methods to existing classes is a better fit for static extensions (no such feature, yet, but it might look like this):

// app_colors.dart
import 'package:flutter/material.dart' as material;

static extension AppColors on material.Colors {
   static Color get myBusinessColor => const Color(0x012345678);
}

Adding functionality to existing classes could be a good fit for a regular extension declaration:

import 'package:openapi/openapi.dart' as openapi;

extension UserExt on openapi.User {
  Map<String, dynamic> toJson() => {"firstName": firstName, "lastName": lastName};
}

Adding static methods to existing classes is still a static extension thing:

static extension StaticUserExt on openapi.User {
  static openapi.User? fromJson(Map<String, dynamic> json) {
    if (json case {'firstName': String firstName, 'lastName: String lastName}) {
      return openapi.User(firstName: firstName, lastName: lastName);
    }
    return null;
  }
}
AlexVegner commented 1 year ago

@eernstg Thanks for detailed explanation.