Symbols

I’d like to start the discussion about the future of Symbols in this topic.


101: symbols came to Crystal from Ruby and the look like :foo or :"foo.bar". Basically, a symbol is a string “baked” in the binary. Every symbol has its own internal number representation. For example, when you run :foo == :foo, it’s (roughly) compiled as n == n, where n is the :foo's internal number. Obviously, comparing numbers is much more efficient than comparing strings, therefore Symbols are great. Aren’t they?


In Ruby symbols can be created in runtime (AFAIK), and all symbols in a Crystal program are known at the startup. Thus said, there are not that much use-cases left for the symbols in real-world Crystal applications, in my opinion.

Most of Symbol usages in the wild can be replaced with Enums. Hash and Named Tuple values can be accessed with String keys.

If you ask me, I see a good use for Symbols Symbol Literals in anything related to compilation-time checks and autocasting Enums.

Directly related: #6736

Afterlife

If Symbols got removed from the runtime and only Symbol Literals left in compilation time, we’ll be able to get the following things working (potentially):

Native autocasting Enums

Related: #6645

enum MyEnum
  Foo
  Bar
end

def foo(bar : MyEnum)
end

No more no overload matches foo with Symbol errors on misspelling (e.g. foo(:baz) :+1:

Case with Enums

Related issue: #6592

case my_enum
when :foo
else
  puts "Not Foo"
end

Would raise on misspelled enum (e.g. when :baz) as well :+1:

Symbol Literals arguments in compilation time

That’s a game changer for me. In ORM DSL design, it’s intuitive to have Query methods like #where(id: 42), which check the arguments against a model’s instance variables during the compilation. And it works now with def where(**values : **T) forall T (i.e. splatted NamedTuple argument). However, it does not work with splatted Tuple argument:

def select(*columns : *T) forall T
  {% pp T %} # => {Symbol, Symbol}
end

query.select(:id, :name)

What I expect is:

{% pp T %} # => {:id, :name} (pair of SymbolLiterals)

It should also work with single free arguments in the same way:

def order_by(column : T, order : Order = :desc) forall T
  {% pp T %} # => :id (SymbolLiteral)
end

query.order_by(:id, :desc)

There might be more use-cases, the post will likely to be updated. Thank you!

I don’t follow your last example. If you want the column names to be available in a macro expression, select should just be a macro method.

struct Query(T)
  def select(*vars: *U) forall U
    {
      {% for u in U %}
        {% found = false %}

        {% for ivar in T.instance_vars %}
          {% if ivar.name.symbolize == u %}
            {{ ivar.annotation(Column)[:key] }},
            {% found = true %}
          {% end %}
        {% end %}

        {% raise "Invalid #{T} var #{u}" %}
      {% end %}
    }
  end
end

class User
  @[Column(key: "id")]
  property the_id : Int32
end

# Convert user variable to an SQL column name, 
# raise in compile-time if not found
Query(User).new.select(:the_id) == {"id"}

Yeah, there’s no way that’s going to work. What’s the type of :the_id? You want each symbol to have its own type, which basically means a ton of types will be created for compiling a program. That’s a no-no.

I think there are better ways to model that. For example select could yield something that has the shape of a user to retrieve that info. So:

Query(User).new.select(&.the_id)

Or:

Query(User).new.select { |u| {u.the_id, u.name } }

where u would be something like UserMeta… these are just ideas.

But in general, I think Crystal is probably not powerful enough to model things like this. That said, I believe Lucky does it somehow… so you should probably check how they do it.

There are already SymbolLiterals. How about resolving them along with TypeNodes? I.e. if compilers sees :(\w+) it turns into a SymbolLiteral:

def select(var : T) forall T
  {% pp T %}
end

select(:the_id) # => SymbolLiteral
select(42)      # => TypeNode, it's the developer's responsibility to raise if needed

Or maybe introduce a special syntax like

def select(var : S) forsymbol S
  {% pp S %} # => SymbolLiteral
end

select(42)   # Compile-time error, expected SymbolLiteral
select(:foo) # OK

the Symbol type already exists. But what you want is that :id has the type Symbol(:id), :foo has the type Symbol(:foo) and so on. Otherwise the compiler can’t know, just from knowing that something is a symbol, which symbol is it.

If you read my initial post, I proposed to remove the Symbol type completely from the language.

I like the Symbol(T) idea, btw. Maybe replace Symbol with Symbol(T), so the T could be read in compile-time and autocast it on calls?

def select(var : T) forall T
  {% pp T %}                 # => Symbol(:foo)
  {% pp T.type_vars.first %} # => :foo (probably SymbolLiteral?) 
end

select(:foo)

Another use-case is inline comparing enums with symbols (similar to case issue):

enum Foo
  Bar
end

pp Foo::Bar == :bar # => true