Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
ThinkDigitalSoftware opened this issue Mar 7, 2020 · 21 comments
Open

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

ThinkDigitalSoftware opened this issue Mar 7, 2020 · 21 comments
Labels
feature Proposed language feature that solves one or more problems

Comments

@ThinkDigitalSoftware
Copy link

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.

@ThinkDigitalSoftware ThinkDigitalSoftware changed the title Add support for an Undefined data type [Feature] Add support for an Undefined data type Mar 7, 2020
@stegrams
Copy link

stegrams commented Mar 8, 2020

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 lrhn transferred this issue from dart-lang/sdk Mar 9, 2020
@lrhn lrhn added the feature Proposed language feature that solves one or more problems label Mar 9, 2020
@lrhn
Copy link
Member

lrhn commented Mar 9, 2020

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:

  • Allow non-constant default values.
    Then it would be

    MapState copyWith({
        Category activeTopCategory = this.activeTopCategory,
        Category activeCategory = this.activeCategory}) => 
        MapState(activeTopCategory: activeTopCategory, activeCategory: activeCategory);

    so an omitted parameter would get the current value, but an explicit null would not.
    (Although that would preclude my wish of making an explicit null mean the same as omitting
    the parameter).

  • Just using a hidden private default-value:

     static const _default = const _CategoryMarker();  // Some special class implementing Category
     MapState copyWith({
         Category activeTopCategory = _default,
         Category activeCategory = _default}) {
       if (identical(activeTopCategory, _default)) activeTopCategory = this.activeTopCategory;
       if (identical(activeCategory, _default)) activeCategory = this.activeCategory;
       return MapState(activeTopCategory: activeTopCategory, activeCategory: activeCategory);
     }

    which you can do already today.

@rrousselGit
Copy link

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
Copy link
Member

lrhn commented Mar 9, 2020

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
Copy link
Author

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
Copy link

rrousselGit commented Mar 9, 2020

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
Copy link
Author

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
Copy link

igotfr commented Apr 10, 2020

A better name would be uninitialized

@mateusfccp
Copy link
Contributor

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
Copy link

igotfr commented Apr 14, 2020

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
Copy link
Member

eernstg commented Apr 14, 2020

@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
Copy link

@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
Copy link
Member

eernstg commented May 26, 2021

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
Copy link

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
Copy link

maRci002 commented Dec 7, 2021

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 😉):

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
Copy link

@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
Copy link

maRci002 commented Dec 7, 2021

@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
Copy link

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
Copy link

maRci002 commented Dec 8, 2021

@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
Copy link

arualana commented Dec 1, 2023

Is this being addressed?

@munificent
Copy link
Member

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Proposed language feature that solves one or more problems
Projects
None yet
Development

No branches or pull requests