Explicit upcast needed for single subclass instance?

Hello,

I encountered this strange behavior today when trying to combine some filters in a crystal application I’m writing. The code below is a simplified version.

If I use a single instance of a filter or multiple instances of the same filter class, the code fails to compile. However, if I use more than one subclass the code compiles.

I find it a bit strange that the compiler knows that a subclass instance can be added to an Array(Superclass) if there are multiple subclass instances, but fails to do so on a single instance. Is this intended behavior?

I can solve this by “upcasting” each subclass instance, but I find it unintuitive that I only need to this when there is a single subclass present.

struct WorkItem
    getter priority : Int32
    def initialize(@priority : Int32)
    end
end

abstract class Filter
    abstract def pass?(item : WorkItem) : Bool
end

class LowPriorityFilter < Filter
    def pass?(item : WorkItem) : Bool
        item.priority <= 5
    end
end

class MediumPriorityFilter < Filter
    def pass?(item : WorkItem) : Bool
        item.priority >= 6 && item.priority <= 10
    end
end

class HighPriorityFilter < Filter
    def pass?(item : WorkItem) : Bool
        item.priority > 10
    end
end

class CombinedFilter < Filter
    @filters : Array(Filter)
    delegate all?, to: @filters # Just for introspection
    def initialize(*filters : Filter)
        @filters = filters.to_a
    end

    def pass?(item : WorkItem) : Bool
        @filters.any? { |filter| filter.pass?(item) }
    end
end

workitems = [
    WorkItem.new(3),
    WorkItem.new(5),
    WorkItem.new(7),
    WorkItem.new(9),
    WorkItem.new(11),
    WorkItem.new(13)
]

# Using more than one filter works without issues
working_combined_filter = CombinedFilter.new(LowPriorityFilter.new, MediumPriorityFilter.new)
working_filtered_items = workitems.select { |item| working_combined_filter.pass?(item) }
puts "All filters inherit from Filter: #{working_combined_filter.all? { |filter| filter.is_a?(Filter) }}"
p working_filtered_items
# => [WorkItem(@priority=3), WorkItem(@priority=5), WorkItem(@priority=7), WorkItem(@priority=9)]

# Using a single filter fails to compile
# nonworking_combined_filter = CombinedFilter.new(HighPriorityFilter.new)
# In inheritance_test.cr:32:9
#
# 33 | @filters = filters.to_a
#      ^-------
# Error: instance variable '@filters' of CombinedFilter must be Array(Filter), not Array(HighPriorityFilter)

# nonworking_filtered_items = workitems.select { |item| nonworking_combined_filter.pass?(item) }
# puts "All filters ingerit from Filter: #{working_combined_filter.all? { |filter| filter.is_a?(Filter) }}"
# p nonworking_filtered_items

The compiler is simplifying the *filters : Filter to filters : Tuple(HighPriorityFilter) in the case of only passing one filter.

One potential way around (don’t know how it compares perf wise):

class CombinedFilter < Filter
    @filters : Array(Filter) = Array(Filter).new
    delegate all?, to: @filters # Just for introspection
    def initialize(*filters : Filter)
        @filters.concat(filters)
    end

    def pass?(item : WorkItem) : Bool
        @filters.any? { |filter| filter.pass?(item) }
    end
end

Old: Carcin
New: Carcin

Yes, actually initializing the array seems to do the trick. Thanks!

I still find the “magic” performed by Tuple => Array conversion a bit strange though:

Tuple(LowPriorityFilter, MediumPriorityFilter).to_a => Array(Filter)
Tuple(HighPriorityFilter).to_a => Array(HighPriorityFilter)

I would have expected to first case to return Array(LowPriorityFilter | MediumPriorityFilter) but it automagically upcasts itself to the superclass.

That’s normal, the compiler automatically casts unions of the same “type” to a supertype (Filter+ in this case) for performance reasons

1 Like

More details on this topic: [RFC] Don't unify types under the same hierarchy as a base virtual type · Issue #2661 · crystal-lang/crystal · GitHub