Slim syntax for struct values

When passing struct values for arguments, is there a slimmer syntax than Point.new(3, 4)? For example, C++ supports {3, 4}. I tried parens, brackets and curlies.

If not, can I register an “automatic converter” from one type (Tuple(Int32, Int32)) to another (Point)?

If you can show us some more code, we may be able to answer your question more effectively.

C++ and Crystal are different languages and different ways of thinking. I would love to answer your question, but I don’t think what you really need here is an automatic converter. I don’t know what you really need.

Crystal supports Hash-like Type literal for hashes and hash-like types.

This requires class to have argument-less constructor and implement []= operator.

But TIL that one can override << operator to achieve Tuple Like literal.

Given below sample code (assuming default values for types). It overrides both []= and << operators to achieve hash-like and tuple like functionality

https://carc.in/#/r/had3

struct Foo
  getter foo : String
  getter bar : Int32
  getter baz : Float64

  def initialize(@foo, @bar, @baz)
  end

  def self.new
    new("", 0, 0.0)
  end

  def <<(value)
    case value
    when String  then @foo = value
    when Int32   then @bar = value
    when Float64 then @baz = value
    end
  end

  def []=(name, value)
    case name
    when "foo" then @foo = value.to_s
    when "bar" then @bar = value.to_i
    when "baz" then @baz = value.to_f
    else
      raise ArgumentError.new("invalid argument")
    end
  end
end

with above code you can create instance in different ways as well

foo = Foo{"foo" => "Hi", "bar" => 12, "baz" => 14.0}
# OR
bar = Foo{"Hi", 12, 14.0}

HIH

//Ali

3 Likes

As for the automatic converter you mentioned, I think you might be referring to function overloading, like this

struct Point
  property x : Int32
  property y : Int32

  def initialize(@x, @y)
  end

  def self.new(p : Tuple(Int32, Int32))
    new(p[0], p[1])
  end

  def self.[](px : Int32, py : Int32)
    new(px, py)
  end
end

struct Tuple
  def to_p : Point
    if self.is_a?(Tuple(Int32, Int32))
      Point.new(self)
    else
      raise "to_p can only be called on Tuple(Int32, Int32)"
    end
  end
end

tu = {3, 4}
pp Point.new(tu) # => Point(@x=3, @y=4)
pp Point[3, 4]   # => Point(@x=3, @y=4)
pp tu.to_p       # => Point(@x=3, @y=4)

I think all of these solutions so far are way more complicated than you need. The gist of it is no, there isn’t a slim syntax like this natively. I’d really just consider typing the few extra chars and just use .new. If you have a tuple you can even just do like Point.new *{1, 2} and it would work the same as Point.new 1, 2 versus handling Tuple itself.

If you really want something similar, you could define a macro [](*args) and have it expand to something like Point.new *args. But ultimately unless you have a good reason to, I’d just keep it simple.

Full code:

struct Point
  getter x : Int32
  getter y : Int32

  macro [](*args)
    {{@type}}.new {{args.splat}}
  end

  def initialize(@x, @y)
  end
end

pp Point.new 1, 2    # => Point(@x=1, @y=2)
pp Point.new *{3, 4} # => Point(@x=3, @y=4)
pp Point[5, 6]       # => Point(@x=5, @y=6)
3 Likes

Thank you for all the responses. @kojix2 had asked for code he’s correct that I should have provided an example:

/*
C++ example of slim syntax for struct literals.
Comes in handy for small things like Point, Color, etc.
compile:
    clang++ -std=c++23 -Wall -Wextra -Werror -o point point.cpp
run:
    ./point
*/

#include <format>

struct Point {
    int x, y;
};

int distance_squared(Point a, Point b) {
    int dx = a.x - b.x;
    int dy = a.y - b.y;
    return dx * dx + dy * dy;
}

int main() {
    // slim syntax used twice in func call arguments:
    int ds = distance_squared({1, 2}, {3, 4});
    std::printf("d: %d\n", ds);
    return 0;
}

Many people write good answers, but I prefer writing code clearly, like Point.new(1, 2).

However, if you really want to shorten it, Point[x, y] is fine.

I’m not a big fan of the record macro, but it does make the code look shorter.

record Point, x : Int32, y : Int32 do
  def self.[](x, y)
    new x, y
  end
end

def distance_squared(a, b)
  (a.x - b.x) ** 2 + (a.y - b.y) ** 2
end

p! distance_squared(Point[1, 2], Point[3, 4])
1 Like

In C++ that syntax works because the type of the arguments is known beforehand. So when saying distance_squared({1, 2}, ...), the compiler knows that {1, 2} is in the position of a Point, and can make the conversion. (BTW, if you overload distance_squared, then it doesn’t work precisely because it doesn’t know anymore the type).

In Crystal types are not mandatory, so in order for it to work, it should do as C++ and only work if there’s no overload of the function/method, and push the conversion until the type is known. Likely doable, but bad for error messages if something doesn’t work.

I agree that the C++ code works due to static typing, which Crystal has as well. I’m not suggesting that {x, y} should/would work in a situation that was ambiguous or untyped.

The C++ code still works when overloading the function:

/*
C++ example of slim syntax for struct literals.
Comes in handy for small things like Point, Color, etc.
compile:
    clang++ -std=c++23 -Wall -Wextra -Werror -o point point.cpp
run:
    ./point
*/

#include <format>

struct Point {
    int x, y;
};

int distance_squared(Point a, Point b) {
    int dx = a.x - b.x;
    int dy = a.y - b.y;
    return dx * dx + dy * dy;
}

int distance_squared(int x1, int y1, int x2, int y2) {
	return distance_squared({x1, y1}, {x2, y2});
}

int main() {
    // slim syntax used twice in func call arguments:
    int ds = distance_squared({1, 2}, {3, 4});
    std::printf("d: %d\n", ds);
    ds = distance_squared(1, 2, 3, 4);
    std::printf("d: %d\n", ds);
    return 0;
}
// output
// d: 8
// d: 8

It only fails when the overloads are ambiguous. For example, using a std::pair<> argument type can trigger call is ambiguous which I have no issue with. In practice, this hardly happens, but when it does, the API needs a rethink.

Btw both C++ and Nim support automatic conversion between types which has come in handy.

1 Like

I also think so, i consider record is one of a few BAD PART in the Crystal, it added unnecessary complexity.

2 Likes

Why? What’s so complex/bad about it? It’s just a struct with getters.

3 Likes

@Blacksmoke16 It looks nice at first but then you’d like methods, so you start extending it with a weird syntax (a block to add methods?) then you want more attributes and it starts being cramped on that line… so in the end you just rewrite as a struct but you loose the methods that record injected…

I never use record in any real code. I just directly create a struct Point.

4 Likes

I wouldn’t call it “bad”, but it’s definitely not as clean as I’d like it to be. A quick rundown of how I think about it.

A regular struct might look something like this:

struct Message
  getter id : UUID
  getter content : String
  getter metadata : Metadata { Metadata.new }
  getter sent_at : Time
  getter processed_at : Time?

  def initialize(@id, @content, @metadata, @sent_at, @processed_at = nil)
  end

  def processed?
    !processed_at.nil?
  end
end

The getters and the args to initialize are duplicated and, in most cases, must change in lockstep. There’s no single source of truth for the object’s state and the initialization of that state, so people reach for record because it does exactly that:

record Message, id : UUID, content : String, sent_at : Time, metadata : Metadata = Metadata.new, processed_at : Time? = nil do
  def processed?
    !processed_at.nil?
  end
end

I lose the benefit of the lazy generation of the metadata ivar because the parser doesn’t parse blocks that way.

And, as @ysbaddaden mentioned, the attributes are too crammed onto a single line, so then you rewrite like this:

record Message,
  id : UUID,
  content : String,
  sent_at : Time,
  metadata : Metadata = Metadata.new,
  processed_at : Time? = nil do
  def processed?
    !processed_at.nil?
  end
end

Now you’ve lost the visual boundary between the getters and regular methods. In regular structs, I use that boundary to indicate “this is where you can look to see what state an object has” and crystal tool format also creates that boundary for me. But with this structure, even if you put an empty line in between, the formatter removes it.

So that’s annoying, so I change my structure to this:

record(
  Message,
  id : UUID,
  content : String,
  sent_at : Time,
  metadata : Metadata = Metadata.new,
  processed_at : Time? = nil,
) do
  def processed?
    !processed_at.nil?
  end
end

This adds that clear boundary between state and behavior, but sacrifices other aesthetics and I wouldn’t call this more readable.

For the :sunglasses: record (YEAAAAAAAAH!), I don’t think the record macro is the right answer to defining initialize and getters in a single place for structs with behavior. It’s amazing for objects that are simply used for holding state. It’s right there in the name — “record” is also the term many FP languages use for types of state. However, it’s the only thing we’ve got right now. It would be really nice if we had something better.

4 Likes

Write Message with record in one line is better, because when filter by the record name use grep, you want to know, the Message is a record.

record(Message,
  id: UUID
  # ...
  ) do
   # ....
end

Another disadvantage is:

When write record use struct directly, you can jump to where the definition of this struct if mouse over the struct name when enable LSP support (crystalline) use find definition. but record macro is not.