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

Splat tuple inside tuple literal #3718

Open
kostya opened this issue Dec 17, 2016 · 11 comments
Open

Splat tuple inside tuple literal #3718

kostya opened this issue Dec 17, 2016 · 11 comments

Comments

@kostya
Copy link
Contributor

kostya commented Dec 17, 2016

this is expected to work: https://play.crystal-lang.org/#/r/1ghu

@asterite
Copy link
Member

t = {1, "bla"}
t2 = {"a", *t}
p t2

Yes, I think I also wanted to do that at least one time. I'll mark this as an enhancement (I'll change the title a bit)

@asterite asterite changed the title bug extending tuple Splat tuple inside tuple literal Dec 17, 2016
@asterite
Copy link
Member

Workaround:

t = {1, "bla"}
t2 = Tuple.new("a", *t)
p t2

Which makes me believe that the compiler could simply make {...} be the same as Tuple.new(...), and this will automatically start working :-)

@HertzDevil
Copy link
Contributor

This was raised on Gitter again, so I'll summarize what I believe each of the literals would look like if splat expansions are allowed inside them:

t = {1, 'a'}
t2 = {true, *t} # => {true, 1, 'a'}
typeof(t2)      # => Tuple(Bool, Int32, Char)

def f(x : {Bool, *{Int32, Char}}); end      # should be okay
def f(x : Tuple(Bool, *{Int32, Char})); end # okay

Tuple literal. This should be fairly straightforward once #10193 is merged. No literal expansion is involved. For consistency, we might also want to allow them in type restrictions.

[exp1, *exp2, *exp3, exp4]

# the above is equivalent to:
ary = ::Array(typeof(exp1, exp2.first, exp3.first, exp4)).new
ary << exp1
ary.concat(exp2) # faster than `each` loop if `exp2` is also an `Array`
ary.concat(exp3)
ary << exp4
ary # `#concat` and `#<<` both return `self` so maybe this can be dropped

Array literal (#462). The splat expressions could denote any Enumerable value, not just Tuple. Since Array always responds to #concat, we could use that instead of iterating over the splat expressions ourselves. Note that we cannot have an initial capacity whenever there are splats, because we cannot call Enumerable#size.

T{exp1, *exp2, *exp3, exp4}

# the above is equivalent to:
ary = T(typeof(exp1, exp2.first, exp3.first, exp4)).new
ary << exp1
exp2.each { |elem| ary << elem }
exp3.each { |elem| ary << elem }
ary << exp4
ary

Array-like literal. T must respond to #<< and have an arg-less constructor. More or less the same, except we cannot assume ary responds to #concat here, and the last ary is mandatory.


Likewise, we can do the same to key-value literals if we support ** inside them. This would be a bit harder to parse because the double splat expressions are obviously not key-value pairs.

t = {y: 1, z: 'a'}
t2 = {x: true, **t} # => {x: true, y: 1, z: 'a'}
typeof(t2)          # => NamedTuple(x: Bool, y: Int32, z: Char)
t3 = {**t, x: true} # => {y: 1, z: 'a', x: true}
typeof(t3)          # => NamedTuple(y: Int32, z: Char, x: Bool)

{**t, y: ""} # => {y: "", z: 'a'}
{y: "", **t} # => {y: 1, z: 'a'}

NamedTuple literal. This requires codegen support, and also new values could overwrite old ones in case of duplicate keys.

{key1 => exp1, **exp2, **exp3, key4 => exp4}

# the above is equivalent to:
hsh = ::Hash(
  typeof(key1, exp2.keys.first, exp3.keys.first, key4),
  typeof(exp1, exp2.values.first, exp3.values.first, exp4),
).new
hsh[key1] = exp1
exp2.each { |k, v| hsh[k] = v } # `Hash#merge!` provides no optimizations over this
exp3.each { |k, v| hsh[k] = v }
hsh[key4] = exp4
hsh

Hash literal. The case for hash-like literals is exactly the same, except ::Hash is replaced with a different generic type name. Here we have a problem: what is the expected interface for exp2 and exp3? They should be hash-like values, but Crystal doesn't have a module for hash-like types. I believe that responding to #keys and #values would be a good fit, which restricts the implementing types in the standard library to Hash, NamedTuple, and HTTP::Headers (through forward_missing_to).

If it were an Enumerable, then the typeof part would become exp2.first[0] and exp2.first[1] respectively. (Note that NamedTuple doesn't respond to #first because it doesn't, or rather cannot include Enumerable.) Ruby also doesn't allow arbitrary Enumerables, but instead relies on #to_hash for double splat expansions, and similarly #to_a (not #to_ary) for single splats:

{1 => 'a', **[[2, 'b'], [3, 'c']]} # TypeError (no implicit conversion of Array into Hash)

class Array
  def to_hash; to_h; end
end

{1 => 'a', **[[2, 'b'], [3, 'c']]} # => {1=>"a", 2=>"b", 3=>"c"}

[1, *"abc"] # => [1, "abc"]

class String
  def to_a; chars; end
end

[1, *"abc"] # => [1, "a", "b", "c"]

@straight-shoota
Copy link
Member

Splatting tuples would allow to replace the macros in Tuple#+ and NamedTuple#merge with {*self, *other} and {**self, **other}, respectively.

I'm really not sure about Hash literals. That should probably be defered and discussed in details. I don't think it holds up any of the other features.

@asterite
Copy link
Member

I think we can start with Tuple and Array-like types. When I thought about this I forgot we need typeof for the splat parts.

We can probably introduce a convention to mean "this can be array-splatted" and "this can be hash-splatted". Then array-splatted types need size and []. And hash-splatted types need size and [] too. Something like:

class Indexable
  def to_array_splat
    self
  end
end

class Hash
  def to_hash_splat
    self
  end
end

class NamedTuple
  def to_hash_splat
    self
  end

  def first
    # something that returns a tuple with two elements
  end
end

That way splatting Array into Hash will not compile. And trying to unsplat or splat a String also won't work.

@HertzDevil
Copy link
Contributor

HertzDevil commented Feb 20, 2021

What's the rationale for #size? The transformations here do not require it at all (except perhaps for pre-allocating the container with the correct capacity, you know which issue I'm referring to). Do we exclude arbitrary Enumerables in the case for array(-like) literals?

EDIT: Yes 1-to-n multiple assignment will need #size, but not here.

Even #first is technically not required here if we expand its definition inside the typeof or extract it into some internal method, for non-Enumerable types that do implement #each(&):

def __crystal_first(x)
  x.each { |elem| return elem }
  raise ""
end

[*exp] # calls ::Array(typeof(::__crystal_first(exp.to_array_splat))).new

This way we don't have to rely on #first solely for type deduction purposes (especially for #to_hash_splat where a "first" element may not even make sense). The standard library types that do this are CSV, Enum, LLVM::FunctionCollection, NamedTuple, and YAML::Nodes::Mapping.

@asterite
Copy link
Member

@HertzDevil sounds good. Yes, I was thinking size is only needed when unpacking

@straight-shoota
Copy link
Member

typeof(exp.first) doesn't work if exp is a tuple: [*{1, 'a'}] results in invalid expansion because the array type is deduced to be only Array(Int32) when it should be Array(Char | Int32).
I tried this with #10429 and it fails as expected. Adding special handling for tuple types should fix this.

@HertzDevil
Copy link
Contributor

HertzDevil commented Feb 25, 2021

unsafe_fetch(0) should work, provided nothing tries to return a value in a completely different type in case of out-of-bound access. That doesn't work with types that aren't Indexable.

@straight-shoota
Copy link
Member

Hm, why not [0]?

@HertzDevil
Copy link
Contributor

HertzDevil commented Feb 25, 2021

exp.first is exactly exp[0] so it won't change anything. However exp[0 + 0] works, as does the longer (i = 0; exp[i]).

But now I'm worrying about whether existing methods that rely on first's type are already broken...

EDIT:

{1, 'a'}.reject(Int32)        # Error: no overload matches 'Array(NoReturn)#<<' with type Char
{ {1, 'a'}, {"", true} }.to_h # Error: no overload matches 'Hash(Int32, Char)#[]=' with types (Int32 | String), Char
[{1, 'a'}].transpose          # Error: expected block to return T, not (Char | Int32)

NamedTuple does similar type operations, but it is smart enough to do this:

struct NamedTuple
  def map
    array = Array(typeof(yield first_key_internal, first_value_internal)).new(size)
    # ...
  end

  private def first_key_internal
    i = 0
    keys[i]
  end

  private def first_value_internal
    i = 0
    values[i]
  end
end

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

No branches or pull requests

5 participants