Skip to content

Set-theoretic tuple types #13576

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

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 226 additions & 8 deletions lib/elixir/lib/module/types/descr.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,22 @@ defmodule Module.Types.Descr do
@bit_reference 1 <<< 6

@bit_non_empty_list 1 <<< 7
@bit_tuple 1 <<< 8
@bit_fun 1 <<< 9
@bit_top (1 <<< 10) - 1
@bit_fun 1 <<< 8
@bit_top (1 <<< 9) - 1

@bit_list @bit_empty_list ||| @bit_non_empty_list
@bit_number @bit_integer ||| @bit_float
@bit_optional 1 <<< 10
@bit_optional 1 <<< 9

@atom_top {:negation, :sets.new(version: 2)}
@tuple_top [{:open, %{}, []}]
@tuple_empty [{:closed, %{}, []}]
@map_top [{:open, %{}, []}]
@map_empty [{:closed, %{}, []}]

# Guard helpers

@term %{bitmap: @bit_top, atom: @atom_top, map: @map_top}
@term %{bitmap: @bit_top, atom: @atom_top, tuple: @tuple_top, map: @map_top}
@none %{}
@dynamic %{dynamic: @term}

Expand All @@ -63,7 +64,11 @@ defmodule Module.Types.Descr do
def pid(), do: %{bitmap: @bit_pid}
def port(), do: %{bitmap: @bit_port}
def reference(), do: %{bitmap: @bit_reference}
def tuple(), do: %{bitmap: @bit_tuple}
def empty_tuple(), do: %{tuple: @tuple_empty}
def tuple(), do: %{tuple: @tuple_top}
def open_tuple(), do: %{tuple: @tuple_top}
def open_tuple(elements), do: %{tuple: tuple_new(:open, elements)}
def tuple(elements), do: %{tuple: tuple_new(:closed, elements)}

@boolset :sets.from_list([true, false], version: 2)
def boolean(), do: %{atom: {:union, @boolset}}
Expand All @@ -81,7 +86,12 @@ defmodule Module.Types.Descr do
# `not_set()` has no meaning outside of map types.

@not_set %{bitmap: @bit_optional}
@term_or_optional %{bitmap: @bit_top ||| @bit_optional, atom: @atom_top, map: @map_top}
@term_or_optional %{
bitmap: @bit_top ||| @bit_optional,
atom: @atom_top,
tuple: @tuple_top,
map: @map_top
}

def not_set(), do: @not_set
def if_set(type), do: Map.update(type, :bitmap, @bit_optional, &(&1 ||| @bit_optional))
Expand Down Expand Up @@ -132,6 +142,7 @@ defmodule Module.Types.Descr do
defp union(:atom, v1, v2), do: atom_union(v1, v2)
defp union(:dynamic, v1, v2), do: dynamic_union(v1, v2)
defp union(:map, v1, v2), do: map_union(v1, v2)
defp union(:tuple, v1, v2), do: tuple_union(v1, v2)

@doc """
Computes the intersection of two descrs.
Expand Down Expand Up @@ -160,6 +171,7 @@ defmodule Module.Types.Descr do
defp intersection(:atom, v1, v2), do: atom_intersection(v1, v2)
defp intersection(:dynamic, v1, v2), do: dynamic_intersection(v1, v2)
defp intersection(:map, v1, v2), do: map_intersection(v1, v2)
defp intersection(:tuple, v1, v2), do: tuple_intersection(v1, v2)

@doc """
Computes the difference between two types.
Expand Down Expand Up @@ -189,6 +201,7 @@ defmodule Module.Types.Descr do
defp difference(:atom, v1, v2), do: atom_difference(v1, v2)
defp difference(:dynamic, v1, v2), do: dynamic_difference(v1, v2)
defp difference(:map, v1, v2), do: map_difference(v1, v2)
defp difference(:tuple, v1, v2), do: tuple_difference(v1, v2)

@doc """
Compute the negation of a type.
Expand All @@ -208,6 +221,7 @@ defmodule Module.Types.Descr do

descr == @none or
(not Map.has_key?(descr, :bitmap) and not Map.has_key?(descr, :atom) and
(not Map.has_key?(descr, :tuple) or tuple_empty?(descr.tuple)) and
(not Map.has_key?(descr, :map) or map_empty?(descr.map)))
end

Expand All @@ -230,6 +244,7 @@ defmodule Module.Types.Descr do
defp to_quoted(:atom, val), do: atom_to_quoted(val)
defp to_quoted(:dynamic, descr), do: dynamic_to_quoted(descr)
defp to_quoted(:map, dnf), do: map_to_quoted(dnf)
defp to_quoted(:tuple, dnf), do: tuple_to_quoted(dnf)

@doc """
Converts a descr to its quoted string representation.
Expand Down Expand Up @@ -361,7 +376,6 @@ defmodule Module.Types.Descr do
port: @bit_port,
reference: @bit_reference,
non_empty_list: @bit_non_empty_list,
tuple: @bit_tuple,
fun: @bit_fun
]

Expand Down Expand Up @@ -672,6 +686,19 @@ defmodule Module.Types.Descr do
end
end

defp remove_optional(type) do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use the existing pop_optional instead!

case type do
%{bitmap: @bit_optional} ->
Map.delete(type, :bitmap)

%{bitmap: bitmap} when (bitmap &&& @bit_optional) != 0 ->
%{type | bitmap: bitmap - @bit_optional}

_ ->
type
end
end

# Union is list concatenation
defp map_union(dnf1, dnf2), do: dnf1 ++ dnf2

Expand Down Expand Up @@ -954,6 +981,197 @@ defmodule Module.Types.Descr do
end
end

## Tuple

# Tuple types {integer(), atom()} and open tuple types {atom(), boolean(), ...}
# which represents every tuple of at least two elements that are an atom and a boolean.
#
# Tuples are encoded as map records, where the keys are the indices.
# E.g., type {integer(), atom()} is encoded as type %{0 => integer(), 1 => atom()}.
# and {atom(), boolean(), ...} is encoded as the open map %{..., 0 => atom(), 1 => boolean()}.
#
# There is no overlap because map types and tuple types exist in different fields
# of the descr (:map and :tuple). While this encoding reuses the set-theoretic
# map operations, emptiness is slightly modified to account for the fact that
# tuple types do not admit optional indices.

defp tuple_new(tag, elements) do
pairs =
elements
|> Enum.map_reduce(0, fn type, acc -> {{acc, type}, acc + 1} end)
|> elem(0)

map_new(tag, :maps.from_list(pairs))
end

defp tuple_intersection(left, right), do: map_intersection(left, right)
defp tuple_difference(left, right), do: map_difference(left, right)
defp tuple_union(left, right), do: map_union(left, right)

# Same as map_fetch, only the tuple descr field is accessed
def tuple_fetch(%{} = descr, key) do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def tuple_fetch(%{} = descr, key) do
def tuple_fetch(%{} = descr, key) when is_integer(key) do

case :maps.take(:dynamic, descr) do
:error ->
if is_map_key(descr, :tuple) and tuple_only?(descr) do
{static_optional?, static_type} = tuple_fetch_static(descr, key) |> pop_optional()

if static_optional? or empty?(static_type) do
:badindex
else
{false, static_type}
end
else
:badtuple
end

{%{map: {:open, fields, []}}, static} when fields == %{} and static == @none ->
{true, dynamic()}

{dynamic, static} ->
if is_map_key(dynamic, :tuple) and tuple_only?(static) do
{dynamic_optional?, dynamic_type} = tuple_fetch_static(dynamic, key) |> pop_optional()
{static_optional?, static_type} = tuple_fetch_static(static, key) |> pop_optional()

if static_optional? or empty?(dynamic_type) do
:badindex
else
{dynamic_optional?, union(dynamic(dynamic_type), static_type)}
end
else
:badtuple
end
end
end

defp tuple_only?(descr), do: empty?(Map.delete(descr, :tuple))

defp tuple_fetch_static(descr, index) when is_integer(index) do
case descr do
%{tuple: tuple} -> Enum.reduce(map_split_on_key(tuple, index), none(), &union/2)
%{} -> none()
end
end

defp tuple_to_quoted(dnf) do
dnf
|> map_normalize()
|> Enum.map(&tuple_each_to_quoted/1)
|> case do
[] -> []
dnf -> Enum.reduce(dnf, &{:or, [], [&2, &1]}) |> List.wrap()
end
end

defp tuple_each_to_quoted({tag, positive_map, negative_maps}) do
case negative_maps do
[] ->
tuple_literal_to_quoted({tag, positive_map})

_ ->
negative_maps
|> Enum.map(&tuple_literal_to_quoted/1)
|> Enum.reduce(&{:or, [], [&2, &1]})
|> Kernel.then(
&{:and, [], [tuple_literal_to_quoted({tag, positive_map}), {:not, [], [&1]}]}
)
end
end

def tuple_literal_to_quoted({:closed, fields}) when map_size(fields) == 0 do
{:empty_map, [], []}
end

def tuple_literal_to_quoted({tag, fields}) do
case tag do
:closed -> {:{}, [], tuple_fields_to_quoted(tag, fields)}
:open -> {:{}, [], tuple_fields_to_quoted(tag, fields) ++ [{:..., [], nil}]}
end
end

defp tuple_fields_to_quoted(tag, map) do
sorted = Enum.sort(map)

for {_, type} <- sorted,
not (tag == :open and optional?(type) and term_type?(type)) do
cond do
not optional?(type) -> to_quoted(type)
empty?(type) -> {:not_set, [], []}
true -> {:if_set, [], [to_quoted(type)]}
end
end
end

# Emptiness checking for tuples.
#
# Short-circuits if it finds a non-empty map tuple in the union.
# Since the algorithm is recursive, we implement the short-circuiting
# as throw/catch.
#
# For tuples, the explicit types do not contain optional
defp tuple_empty?(dnf) do
try do
for {tag, pos, negs} <- dnf do
tuple_empty?(tag, pos, negs)
end

true
catch
:not_empty -> false
end
end

defp tuple_empty?(_, _, []), do: throw(:not_empty)
defp tuple_empty?(_, _, [{:open, fs} | _]) when fs == %{}, do: true

defp tuple_empty?(tag, fields, [{neg_tag, neg_fields} | negs]) do
try do
# Indices that exists in the negative tuple, but not in the positive one
for {neg_index, neg_type} <- neg_fields, not is_map_key(fields, neg_index) do
cond do
# This index is not in the closed positive tuple, so there is no value in common
# Tthere are no explicit optional types in tuples
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Tthere are no explicit optional types in tuples
# There are no explicit optional types in tuples

tag == :closed ->
throw(:discard_negative)

# There may be value in common
tag == :open ->
diff = difference(term_or_optional(), neg_type)

# Remove all the elements with an index larger or equal to neg_index
keys_to_drop = for key <- Map.keys(fields), key >= neg_index, do: key
closed_pos = Map.drop(fields, keys_to_drop)

# For neg_key index, remove the absent part
open_pos = Map.put(fields, neg_index, remove_optional(diff))

tuple_empty?(:closed, closed_pos, negs) and tuple_empty?(tag, open_pos, negs)
end
end

# Indices from the positive tuple that may be present in the negative one
for {index, type} <- fields do
case neg_fields do
%{^index => neg_type} ->
diff = difference(type, neg_type)
empty?(diff) or tuple_empty?(tag, Map.put(fields, index, diff), negs)

%{} ->
if neg_tag == :closed do
throw(:discard_negative)
else
# an absent key in a open negative map can be ignored
diff = difference(type, map_tag_to_type(neg_tag))
empty?(diff) or tuple_empty?(tag, Map.put(fields, index, diff), negs)
end
end
end

true
catch
:discard_negative -> tuple_empty?(tag, fields, negs)
end
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we rewrite it to a non-raising versaion like the map one is currently implemented? :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

@gldubc gldubc May 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes! I see this now thank you :)


## Pairs
#
# To simplify disjunctive normal forms of e.g., map types, it is useful to
Expand Down
Loading