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

Object init shorthand #418

Open
dom96 opened this issue Aug 28, 2021 · 21 comments
Open

Object init shorthand #418

dom96 opened this issue Aug 28, 2021 · 21 comments

Comments

@dom96
Copy link
Contributor

dom96 commented Aug 28, 2021

Consider the following object type:

type
  User = object
    name: string
    age: int
    last_online: float

It's very common to write a constructor for such types, some may have many more fields than the above, having to write the name of each field gets tedious:

proc initUser(name: string, age: int): User =
  User(
    name: name,
    age: age,
    last_online: epochTime()
  )

This RFC proposes to implement a short-hand syntax for the object construction syntax to simplify this code:

proc initUser(name: string, age: int): User =
  User(
    name,
    age,
    last_online: epochTime()
  )

The variable names have to match that of the field name, so User(foo, age) won't work even if foo is a string.

@konsumlamm
Copy link

Rust also has this shorthand syntax and I'm not aware of any problems with it.

@timotheecour
Copy link
Member

timotheecour commented Aug 28, 2021

Here's my counter-proposal, which I used in nim-lang/fusion#32 + other places (but the proposal below improves upon it to allow more flexibility)

# in sugar.nim:
import std/macros

macro objInit*(obj: typed, a: varargs[untyped]): untyped =
  ##[
  Generates an object constructor call from a list of fields.
  ]##
  result = nnkObjConstr.newTree(obj)
  for ai in a:
    case ai.kind
    of nnkExprEqExpr:
      result.add nnkExprColonExpr.newTree(ai[0], ai[1])
    of nnkIdent:
      result.add nnkExprColonExpr.newTree(ai, ai)
    else:
      doAssert false, $ai.kind

usage

type
  User = object
    name: string
    age: int
    last_online: float

proc init(T: typedesc[User], name: string, age: int, bar: int): T =
  T.objInit(name, age, last_online = bar.float * 10.0)

doAssert User.init("ab", 3, 4) == User(name: "ab", age: 3, last_online: 40.0)

benefits

  • DRY: the type name User is used only once in the init declaration in user code
  • can be used in generic code, unlike initUser:
proc baz(a: T) =
  let b = T.init(...)
  • doesn't need compiler support and more explicit (ie, less magic) than supporting User(name, age), hence can be used in stdlib without breaking parser for nim versions prior to this change
  • allows custom fields as demonstrated above (the last_online = bar.float * 10.0)

@haxscramper
Copy link

haxscramper commented Aug 29, 2021

This can easily be done using macro, so I don't think there is a need for dedicated compiler support. Also, Type(arg1, arg2) mixes with proc(arg1, arg2) syntax, while T.init is more explicit, and also works with generics.

What nim really lacks is a standardized way to construct objects, and I don't think there is a need to add yet another one. We already have quite a few, all with their own limitations

  • MyType(field: a, field2: b) - very verbose, does not work with generics, you can't set default values (User-defined implicit initialization hooks #252), you can't validate input values on construction.
  • newT(), initT() - does not work with generics, requires manual implementation (which can be done using macro), but does support default values and input validation.
  • init[T](t: typedesc[T]), new[T](t: typedesc[T]) - also requires manual implementation, not widely used, but IMO this is the best option as it supports generics, which none of the previous versions do, also matches default[T](t: typedesc[T]).

If default(T) is supposed to be a default way to construct objects (it is still not injected in var: declarations, but can at least be used in different templates to make them work with {.requiresinit.}), then we should also consider init(T).


Also, how does this proposal intend to deal with inheritance, when sections, variant objects or different combinations of them (like inheritance from variant object, or multiple fields, or #368)?

@Araq
Copy link
Member

Araq commented Aug 29, 2021

A couple of general remarks:

  • The constructor syntax would work better with the object field = value default value support. (It's an accepted RFC...)
  • Likewise, we should make the constructors infer generic types.
  • Some support for in-place construction ("inplace new") like in C++ would be really nice.
  • Wrapping the constructor in a proc can lose crucial information for precise alias analysis.
  • Shortening the construction syntax ... Sure, why not, but not important for me personally.

@haxscramper
Copy link

By in-place construction, do you mean aggregate initialization?

struct S {
    int x;
    struct Foo {
        int i;
        int j;
        int a[3];
    } b;
};
 
int main()
{
    S s1 = { 1, { 2, 3, {4, 5, 6} } };

@Araq
Copy link
Member

Araq commented Aug 29, 2021

No, mean "placement new":

  T* tptr = new(buf) T; // Construct a `T` object, placing it directly into your 
                        // pre-allocated storage at memory address `buf`.

It's useful for optimizing myseq.add Obj(field: value, ...)

@Varriount
Copy link

Just want to put this out here: https://docs.python.org/3/library/dataclasses.html

It strikes me that a similar module would solve the "common initialization logic" problem, as well as some other common requests.

@juancarlospaco
Copy link
Contributor

I am not sure this belongs in the stdlib.
Also it seems relatively simple to implement manually when needed.

@Varriount
Copy link

Varriount commented Aug 31, 2021

I am not sure this belongs in the stdlib.
Also it seems relatively simple to implement manually when needed.

I can see a "dataclasses"-like module being more effective as part of the standard library, because it would (likely) set a de facto standard. A problem with encapsulating interoperability mechanisms in an external module is its effectiveness relies on how ubiquitously it is used. If the community doesn't gather around a single module, then this has the potential to happen.

(Please note the use of "likely", "potential", etc. in the paragraph above. The above is a possible outcome, but not a certainty)

@Clonkk
Copy link

Clonkk commented Aug 31, 2021

I'm surprised noone mentionned https://github.com/beef331/constructor#construct

Creating new object sure could use some quality of life stuff; the question is whether or not syntactic sugar has its place in the stdlib.

@disruptek
Copy link

Just once, it'd be nice to see a thing added to stdlib because it's /annoyingly/ absent and /everyone/ has to depend upon it as a requirement which /rarely/ changes and works with /all/ supported compiler versions.

Unlike, for example, this case, where neither the syntax nor the implementation will likely satisfy everyone, so it just becomes an immediate burden. But by all means, bike shed away.

What if the burden was merely in adding it as a requirement to your package manager? Unthinkable? Really?

@beef331
Copy link

beef331 commented Sep 1, 2021

The present object constructor syntax(aside from not inferring generics) is fine, if you something more sweet you can easily write your own or use a package(as disruptek alluded to). As such here is yet another way to construct objects in a DRY manner cause I apparently love volume.

import constructor/constructor
type
  User = object
    name: string
    age: int
    lastOnline: float

proc initUser*(name: string, age: int): User {.constr.} = discard

proc init(T: typedesc[User], name: string, age: int) {.constr.} =
  let lastOnline = 30f

assert initUser("hello", 10) == User(name: "hello", lastOnline: 0f, age: 10)
assert User.init("hello", 30) == User(name: "hello", lastOnline: 30f, age: 30)

@demotomohiro
Copy link

I also wrote a macro that create a object type definition from a constructor proc:
https://github.com/demotomohiro/objectDef

But I'm not using it so much. Standard Nim way to define an object type and a constructor proc is fine.

@ZoomRmc
Copy link

ZoomRmc commented Mar 25, 2023

Seeing as @ringabout is already working on an implementation, I'd like to consider the potential issues with a simple positional syntax proposed.

Here's the example @ringabout left on the forum:

type
  Vector = object
    a: int = 1
    b, c: int

block: # positional construction
  var x = Vector(1, 2, 3)
  echo x

This example is bad at demonstrating the usefulness of shorthand constructs - why not use a tuple? - and good ad demonstrating its cons. If it's really an object with all fields of the same type, as soon as anyone touches the declaration and changes the order of fields, the code will break everywhere the object's used with no warnings. What you get is a weird tuple.

If the fields are of mixed types, there's still a chance (albeit much smaller one.) the code will compile and break. The language should resist accommodating more syntax constructs with such qualities. Relying on order for tuples and procs is enough of a footgun.

Named fields are an annoyance, but they are a correctness guarantee.

This RFC proposes allowing for a short-version construction only if the field name matches the variable name. And this looks reasonable enough. #21559 doesn't implement this RFC and should be discussed separately, possibly with a voting/comparison with different approaches:

  • Positional constructs in the language
  • Positional constructs in sugar
  • Name-matched constructs in the language
  • Name-matched constructs in sugar
  • Other options
  • Leave as is.

Personally, I think this is more an issue of tooling. Proper autocompletion and tooltips would reduce the crux of the problem to just a few tab presses for each construction.

@metagn
Copy link
Contributor

metagn commented Mar 26, 2023

The bare minimum you could do is require {.positional.} on the type

Also both have a similar problem:

type
  Foo = object
    x: int
converter toFoo(a: int): Foo =
  Foo(x: -a)
echo Foo(3).x # 3 or -3?

import algorithm
type
  Bar = object
    x: string
converter toBar(b: string): Bar =
  Bar(x: b.reversed)
let x = "abc"
echo Bar(x).x # abc or cba?

Personally I have no idea what the big deal is with just typing the field name out. Maybe if you have giant types and you need to keep typing name: name, age: age, ...? But at that point writing name, age wouldn't really be an improvement, you would get better results with a macro

@ZoomRmc
Copy link

ZoomRmc commented Mar 26, 2023

If you really need positional initializers, I have a painless solution. Just define a macro which generates the converter from an anonymous tuple, maybe with just a pragma. This way all you need to do is just add another pair of parentheses or just a single dot, but the resulting bit of syntax is visually distinct and requires zero changes to the language.

type
  Adj = enum
    Mimsy, Galumphing, Slithy
  Bandersnatch = object
    name: string
    age: int
    kind: Adj

# This converter could be generated with a macro
converter toBandersnatch(x: (string, int, Adj)): Bandersnatch =
  Bandersnatch(name: x[0], age: x[1], kind: x[2])

# Regular object init
let boojum = Bandersnatch(name: "Boojum", age: 147, kind: Slithy)

# Two ways to init an object positionally with a converter
let snark = ("Snark", 147, Galumphing).Bandersnatch
let jabberwocky = Bandersnatch(("Jabberwocky", 147, Galumphing))

Just add this to sugar and don't multiply rules with edge cases (like a single field in this case).

@Araq
Copy link
Member

Araq commented Mar 31, 2023

The converter solution is way uglier and has unintented consequences like:

let unrelatedTuple = ("Snark", 147, Galumphing)
proc takesBandersnatch(unrelatedTuple) # compiles

I've read all your concerns but I am not convinced positional values in object constructors will cause any problems in practice. The feature exists in Rust/C/C++ without many known downsides. A macro cannot accomplish the same as easily as it would need to do type introspection the syntax would look like construct(ObjType, value1, value2).

@ZoomRmc
Copy link

ZoomRmc commented Mar 31, 2023

The converter solution is way uglier and has unintented consequences like:

let unrelatedTuple = ("Snark", 147, Galumphing)
proc takesBandersnatch(unrelatedTuple) # compiles

What's wrong with it and why is it unintended? It does exactly the thing positional inits propose to solve: lets the initializer take a bunch of stuff and reason about it solely based on type matching. What is passing a bunch of arguments to a function/initializer/converter if not passing it an unnamed tuple?

Moreover, positional inits muddy up the obviousness of the syntax. Why is Foo(1, 2, 3) allowed if Foo is an object, but not allowed if it's a tuple? You'd expect it to be the other way around. For tuples you either need to use the type identifier on the left side or an explicit dot/paren conversion on the right side. The limitation stems from the need to somehow distinguish syntaxes for both types (which reuse the same parentheses, unlike Rust) and positional inits erase another visual distinction between them, which is not great.

I've read all your concerns but I am not convinced positional values in object constructors will cause any problems in practice. The feature exists in Rust/C/C++ without many known downsides.

Well, Rust doesn't feature positional identifiers (see below) exactly because it's considered C's mistake worth fixing. Rust only has equal-name inits, like this RFC proposes. Even that shorthand was met with some well-argued resistance, even though the RFC is much more considerate and convincing.

struct Foo {a: u8, b: u32, c: bool}

fn  main() {
    let a = 42u8;
    let b = 90210u32;
    let c = true;
    
    let foo = Foo {a: 0, b: 1, c: false};    
    let bar = Foo {a, b, c};
    // let xyz = Foo {a, b, false};   // Error!
    // let baz = Foo {0, 1, false};   // Error!
}

BTW, there's already a conceptual omission with type conversions and object initializers. From the syntax you expect it to be just a way to destructure tuples into type's fields, but it's really not, it's some special kind of syntax (why?).

type Bandersnatch = object
  s, t: string

#       ↓type ↓tuple
let a = string("a") # Function `string` takes a tuple with anything "equivalent" to a string, returns a typed value
#       ↓type   ↓tuple
let b = string  ("b") # type ← tuple ⇒ Ok, same "single field" destructuring as above
#       ↓type       ↓tuple
let c = Bandersnatch(t: "a", s: "b") # Naming fields allows not respecting the order for destructuring
#       ↓type         ↓tuple
let d = Bandersnatch  (s: "a", t: "b") # Type mismatch error. Why? Because it's not really destructuring
#       ↓type                ↓tuple
let e = tuple[string, string]("a", "b") # Error. Yep, our intuition is wrong

At the very least a serious reason should be given why this feature needs to be a part of the language if so many options are already available (bunch of third-party macros and the tuple conversion approach). Looks like it's just much easier to implement than, say, pattern-matching, so it's given a go, even though it multiplies ambiguity, potentially bug-prone and adds corner-cases like a single field init.

@ringabout
Copy link
Member

The bare minimum you could do is require {.positional.} on the type

Also both have a similar problem:

type
  Foo = object
    x: int
converter toFoo(a: int): Foo =
  Foo(x: -a)
echo Foo(3).x # 3 or -3?

import algorithm
type
  Bar = object
    x: string
converter toBar(b: string): Bar =
  Bar(x: b.reversed)
let x = "abc"
echo Bar(x).x # abc or cba?

Nope, the object with only one field should always use named field values. Foo(3) and Bar(x) are always interpreted as type conversions.

So the answer is -3 and cba.

@ringabout
Copy link
Member

ringabout commented Mar 31, 2023

Here is my alternative RFC => #517

@ringabout
Copy link
Member

ringabout commented Mar 31, 2023

If it's really an object with all fields of the same type, as soon as anyone touches the declaration and changes the order of fields, the code will break everywhere the object's used with no warnings.

Functions with postional parameters have the same disadvantages, but are still used commonly. Probably it just means the project, which is broken, doesn't have enough tests.

Though, on the other hand this RFC should be easier to implement.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.