Open ThinkDigitalSoftware opened 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.
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.
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);
}
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.
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
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),
);
}
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.
A better name would be uninitialized
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.
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
@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.
@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
.
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? ;-)
I agree that having undefined
alongside null
would just make things too complicated. I guess the issues here are
late
variable has been initialized (I suppose making it nullable is probably better)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
}
@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.
@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
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
@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
}
Is this being addressed?
No concrete plans yet, but we're definitely aware of the issue with trying to implement copyWith()
and are discussing multiple possible solutions.
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 thatnull
values can be differentiated from values that have not been defined.Use case:
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.