dart-lang / language

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

[Feature] Add support for an Undefined data type #877

Open ThinkDigitalSoftware opened 4 years ago

ThinkDigitalSoftware commented 4 years ago

Since we have this type in other languages, it would be nice to have it in Dart. It'll greatly strengthen the usefulness of copyWith functions, so that null values can be differentiated from values that have not been defined.

Use case:

class MapState1 {
  MapState copyWith({
    Category activeTopCategory,
    Category activeCategory,

  }) {
    return MapState(
      activeTopCategory: activeTopCategory ?? this.activeTopCategory,
      activeCategory: activeCategory ?? this.activeCategory,
    );
  }

If I decide to set the activeCategory to null, the newly created object will have the same values as the old object. I cannot figure out a reasonable workaround for this.

The second use-case is more common in widgets with optional named parameters with default values. If someone passes in that value using a variable and that variable turns out to be null, we don't get the default value, and there's no way to pass in a value sometimes and leave it undefined at others without creating 2 separate widgets.

stegrams commented 4 years ago

The second use-case emerges also when I try to extend a constructor with optional default values. I haven't figure out how to form the super invocation in order to not overlap the base class default values.

lrhn commented 4 years ago

While I have had the same issue, I'm not sure adding a new type is a good solution in a statically typed language like Dart.

If we add an Undefined type and a corresponding undefined value, we need to figure out where those fit into the current type system, and into the next type system where not all types are nullable. I just don't see any good solution to that. We'd have all the same complexities as for Null in the Null Sound type system, just multiplied by 2. We'd need a way to express that the parameter can be Category or Undefined.

I see much more promise in other approaches. For example:

rrousselGit commented 4 years ago

IMO this would be properly fixed by union types

This would allow people to make their own Undefined if they need it:

class _Undefined {
  const _Undefined();
}

SomeClass copyWith({
  int  | _Undefined someProperty = const _Undefined(),
}) {
  return SomeClass(someProperty: someProperty is int ? someProperty : this.someProperty);
}
lrhn commented 4 years ago

General union types would indeed solve this too. Then you can use any class you choose as placeholder. The current Null type's use in nullable types is just a special cased union type. WIth general union types you could create your own Null-like types wherever you need them.

ThinkDigitalSoftware commented 4 years ago

If we use Union types, won't this have to be explicitly created for each case that I want to check? Seems like a lot of boilerplate

rrousselGit commented 4 years ago

Well, if we truly want to talk about boilerplate, we could talk about data classes or spread on classes, to make a copyWith built in the language.

In any case, union types are much more flexible. They don't benefit almost exclusively a copyWith method.

And we can simplify it a bit:

// Part that can be extracted into a separate file
class Undefined {
  const Undefined();
}

const undefined = Undefined();

T _fallback<T>(T | Undefined value, T fallback) => value is T ? value : fallback;

 // Actual usage

SomeClass copyWith({
  int  | Undefined someProperty = undefined,
  String  | Undefined another = undefined,
}) {
  return SomeClass(
    someProperty: _fallback(someProperty, this.someProperty),
    another: _fallback(another, this.another),
  );
}
ThinkDigitalSoftware commented 4 years ago

This looks great and looks like what I would expect. I'm just used to seeing it work like this in typescript without having to manually define the undefined class.

igotfr commented 4 years ago

A better name would be uninitialized

mateusfccp commented 4 years ago

Please, don't. This will only screw with the semantics of the type system. Null is already bad enough and it's giving a lot of trouble to fix it.

igotfr commented 4 years ago

or only the dart would indicate if the variable was uninitialized

int n;
int m= null;
int p= 2;

print(n); // output: null (uninitialized)
print(m); // output: null
print(o); // output: 2

would continue to be null, there is no need to create a separate type, dart only needs to indicate whether the variable has been initialized or not

eernstg commented 4 years ago

@cindRoberta, you will get a lot of support for tracking the initialization state of variables with the upcoming null-safety feature.

If you just declare variables as usual (without putting a ? on the type), e.g., int i;, then a flow analysis will be used to check that the variable is initialized before it is used. For instance:

void main() {
  int i;
  i = 42; // If you comment this out there is an error in the next line.
  if (i > 0) print(i);
}

This is because i can only have a non-null value (because int, with null-safety, is a type that simply doesn't include null), so the language includes mechanisms to ensure that i is initialized before use. If you use int? i then i can be null, and you need to ensure initialization manually.

You can use a dynamic check as well:

void main() {
  late int i;
  bool b = true; // Assume initialization is complex, we don't know the value.
  if (b) i = 42;
  if (i > 0) print(i);
}

In this case you will get a dynamic error (an exception) when i is evaluated, unless it has been initialized.

The story is a lot longer than this, but as you can see there will be support for tracking initialization when null-safety is released.

Levi-Lesches commented 3 years ago

@eernstg, would this be useful as the default value for late variables, or is that too much like null all over again? I understand the need for a difference between undefined and null, but I also see that whenever we try to safely handle these nulls we just get some other version of null, like late, Optional, or a new undefined.

eernstg commented 3 years ago

An implementation of Dart could very well have a special object (denoted by, say, undefined), and it could initialize every late variable without an initializing expression to undefined, and it could throw whenever an evaluation of such late variables yields undefined. But if we wish to do that then every storage location for such a late variable must be able to hold a reference to undefined, i.e., it couldn't be an unboxed int. But this late behavior can also be achieved by allocating a bit (a bool value) somewhere nearby, which is used to track whether or not the associated variable has been initialized.

Based on that kind of consideration, I'd prefer to rely on language concepts like late rather than concepts like undefined that seem to have heavier implications for the available implementation strategies and optimization opportunities.

So why aren't we just happy with late, leaving the rest to the tool teams? ;-)

Levi-Lesches commented 3 years ago

I agree that having undefined alongside null would just make things too complicated. I guess the issues here are

  1. The important distinction between not including parameters at all and explicitly passing in null
  2. Tracking whether a late variable has been initialized (I suppose making it nullable is probably better)
maRci002 commented 2 years ago

If you are fan of redirected constructor then you have two options to fake undefined:

First create your own undefined class (or just user Never type :wink:):

const _undefined = _Undefined();

class _Undefined {
  const _Undefined();
}

This is what I'm going to copy:

class B {
  final int b1;

  const B({
    required this.b1,
  });

  @override
  String toString() => 'B(b1: $b1)';
}

First option:

abstract class A {
  final B a1;
  final B? a2;

  const A._({
    required this.a1,
    this.a2,
  });

  const factory A({
    required B a1,
    B? a2,
  }) = _A;

  A copyWith({B a1, B? a2});

  @override
  String toString() => 'A(a1: $a1, a2: $a2)';
}

class _A extends A {
  const _A({
    required B a1,
    B? a2,
  }) : super._(a1: a1, a2: a2);

  @override
  A copyWith({Object a1 = _undefined, Object? a2 = _undefined}) {
    return _A(
      a1: a1 == _undefined ? this.a1 : a1 as B,
      a2: a2 == _undefined ? this.a2 : a2 as B?,
    );
  }
}

Second option:

abstract class A {
  const A._();

  const factory A({
    required B a1,
    B? a2,
  }) = _A;

  B get a1;
  B? get a2;

  A copyWith({B a1, B? a2});

  @override
  String toString() => 'A(a1: $a1, a2: $a2)';
}

class _A extends A {
  @override
  final B a1;
  @override
  final B? a2;

  const _A({
    required this.a1,
    this.a2,
  }) : super._();

  @override
  A copyWith({Object a1 = _undefined, Object? a2 = _undefined}) {
    return _A(
      a1: a1 == _undefined ? this.a1 : a1 as B,
      a2: a2 == _undefined ? this.a2 : a2 as B?,
    );
  }
}

Output:

void main() {
  var a = const A(a1: B(b1: 1), a2: B(b1: 2));
  print(a); // A(a1: B(b1: 1), a2: B(b1: 2))

  // undefined won't modify
  a = a.copyWith();
  print(a); // A(a1: B(b1: 1), a2: B(b1: 2))

  // explicit null
  a = a.copyWith(a2: null);
  print(a); // A(a1: B(b1: 1), a2: null)

  print(a is A); // true
  print(a.runtimeType == A); // false
}
Levi-Lesches commented 2 years ago
@override
  A copyWith({Object a1 = _undefined, Object? a2 = _undefined}) {  // (1)
    return _A(
      a1: a1 == _undefined ? this.a1 : a1 as B,  // (2)
      a2: a2 == _undefined ? this.a2 : a2 as B?,  // (2)
    );
  }

The problem with your workaround is that the parameters are defined as Object, which means you lose all type safety. You can see that if a1 or a2 isn't really a B, then the lines a1 as B or a2 as B will throw at runtime. That's not a great solution. Also, you can see there's a lot of code involved in creating and maintaining _A, which really shouldn't be necessary for such a commonly-used function.

maRci002 commented 2 years ago

@Levi-Lesches you won't loose type safety since compiler sees:

A copyWith({B a1, B? a2});
class C {}
a.copyWith(a1: C()); // compile time so you cannot run code: The argument type 'C' can't be assigned to the parameter type 'B'.

You are right about type safety only if a as _A explicit cast is called to help analyzer, however the implementation class shouldn't be exported / directly used in private project.

(a as _A).copyWith(a1: C()); // Unhandled exception: type 'C' is not a subtype of type 'B' in type cast
rrousselGit commented 2 years ago

That's pretty much how Freezed works. With code-generation, it removes the error-prone bits.

The real issue is that this doesn't work with static methods

maRci002 commented 2 years ago

@rrousselGit in case of static method you can store Function as static variable like this:

const _undefined = _Undefined();

class _Undefined {
  const _Undefined();
}

class A {
  static int _counter = 0;
  static int get counter => _counter;

  static void Function({int? c}) myFunction = _myFunction;

  /// if [c] is undefined [_counter] will be decreased by one
  /// if [c] is null [_counter] remains the same
  /// if [c] is int [_counter] will be increased by it
  static void _myFunction({Object? c = _undefined}) {
    if (c == _undefined) {
      _counter--;
    } else if (c != null) {
      _counter += c as int;
    }
  }
}

If Freezed generator wants this behavior then $A.myFunction should point to A._myFunction, however this makes nonsense:

part 'a.freezed.dart';

@freezed
class A with $A {
  static int _counter = 0;
  static int get counter => _counter;

  @freezedStaticFunction(A._myFunction)
  static void Function({int? c}) myFunction = $A.myFunction;

  /// if [c] is undefined [_counter] will be decreased by one
  /// if [c] is null [_counter] will be the same
  /// if [c] is int [_counter] will be increased by it
  static void _myFunction({Object? c = _undefined}) {
    if (c == _undefined) {
      _counter--;
    } else if (c != null) {
      _counter += c as int;
    }
  }
}

Output:

void main(List<String> arguments) {
  A.myFunction(c: null);
  print(A.counter); // 0

  A.myFunction();
  print(A.counter); // -1

  A.myFunction(c: 6);
  print(A.counter); // 5
}
arualana commented 11 months ago

Is this being addressed?

munificent commented 11 months ago

No concrete plans yet, but we're definitely aware of the issue with trying to implement copyWith() and are discussing multiple possible solutions.