feat(report): type checking calls
This commit is contained in:
13
report/appendices/call_dispatcher.typ
Normal file
13
report/appendices/call_dispatcher.typ
Normal file
@@ -0,0 +1,13 @@
|
||||
#import "../requirements.typ": isc-hei-bthesis
|
||||
#import isc-hei-bthesis: code
|
||||
|
||||
#let call-dispatcher-code = raw(
|
||||
read("../code/call_dispatcher.py"),
|
||||
lang: "python",
|
||||
block: true
|
||||
)
|
||||
|
||||
#figure(
|
||||
code(call-dispatcher-code),
|
||||
caption: [Some call dispatcher methods]
|
||||
) <fig:call-dispatcher>
|
||||
@@ -169,6 +169,8 @@ You can also change the order or the names of the sections, for instance, if you
|
||||
|
||||
#include "appendices/python_typer.typ"
|
||||
|
||||
#include "appendices/call_dispatcher.typ"
|
||||
|
||||
#pagebreak()
|
||||
|
||||
= Midas Language Definition <app:midas-syntax>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#import "@preview/codly:1.3.0": codly
|
||||
#import "@preview/lovelace:0.3.1": pseudocode-list
|
||||
#import "../../appendices/python_typer.typ": python-typer-function-code
|
||||
#import "../../appendices/call_dispatcher.typ": call-dispatcher-code
|
||||
|
||||
= Python Type Checking <sec:impl-python>
|
||||
|
||||
@@ -331,7 +332,153 @@ Step 4 is delegated to a common `call_method` function, also used for call expre
|
||||
Similarly, subscripts are de-sugared into calls to `__getitem__`.
|
||||
|
||||
== Type Checking Calls <sec:python-check-calls>
|
||||
#todo[]
|
||||
|
||||
Function calls are not always as straightforward as matching arguments with parameters and getting the return type. For a start, the callee is not necessary a direct `Function`. It may be a `DerivedType` or an `AppliedType`, in which case we must _unfold_ the type hierarchy to get the actual function type. It may also be an `OverloadedFunction`, in which case we must resolve which overload to call. It can also be a `GenericType`, in which case we must try and resolve the actual type arguments based on usage. To keep the type checker tidy, we introduce a separate `CallDispatcher` class, which we can reuse in `MidasTyper` for call expressions too.
|
||||
|
||||
This dispatcher has one main entrypoint: `get_result`. This method, shown in @fig:dispatcher-get_result-signature, takes in a callee type, positional and keyword arguments, and returns a `CallResult` object. This object bundles information about the result type and potential error should the dispatcher fail to resolve the call. The complete methods discusses hereafter are included in @fig:call-dispatcher. Their implementations are available in the repository in #code-ref(<call-dispatcher>, "midas/checker/dispatcher.py").
|
||||
|
||||
#figure(
|
||||
```python
|
||||
class CallResult:
|
||||
error: Optional[CallError] = None
|
||||
result: Type = UnknownType()
|
||||
message: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool: return self.error is None
|
||||
@property
|
||||
def error_message(self) -> str:
|
||||
if self.message is not None:
|
||||
return self.message
|
||||
if self.error is not None:
|
||||
return str(self.error)
|
||||
return ""
|
||||
|
||||
class CallDispatcher(Generic[E]):
|
||||
def get_result(
|
||||
self,
|
||||
location: Location,
|
||||
callee: Type,
|
||||
positional: list[TypedExpr[E]],
|
||||
keywords: dict[str, TypedExpr[E]],
|
||||
report_errors: bool = True,
|
||||
) -> CallResult: ...
|
||||
```,
|
||||
caption: [Call Dispatcher: signature of `get_result` and `CallResult`]
|
||||
) <fig:dispatcher-get_result-signature>
|
||||
|
||||
`CallDispatcher` is generic in `E`, which represents an expression base class. This allows instantiating it both for `m.Expr` and `p.Expr`.
|
||||
|
||||
The simple case where the callee is a `Function` involves matching call-site arguments with parameters in the function's specification. Then, if a match is possible each pair is check for correct typing. Similar to variable assignment (#smallcaps[T-Assign], see @sec:python-var-assign), an argument must be of the same type as the parameter it matches with (module subsumption). Pragmatically, this corresponds to @fig:dispatcher-match-func.
|
||||
|
||||
#codly(
|
||||
range: (14, 24),
|
||||
smart-skip: true
|
||||
)
|
||||
#figure(
|
||||
call-dispatcher-code,
|
||||
caption: [Call Dispatcher: call to `Function`]
|
||||
) <fig:dispatcher-match-func>
|
||||
|
||||
Some other simple cases mentioned above can be handled with recursion. Calls to `UnknownType` are also allowed by the type checker, only resulting in an `UnknownType` too. These form the three simple match-cases in @fig:dispatcher-match-simple.
|
||||
|
||||
#codly(
|
||||
range: (25, 34),
|
||||
smart-skip: true
|
||||
)
|
||||
#figure(
|
||||
call-dispatcher-code,
|
||||
caption: [Call Dispatcher: call to `UnknownType`, `DerivedType` and `AppliedType`]
|
||||
) <fig:dispatcher-match-simple>
|
||||
|
||||
For `OverloadedFunction`, we must first resolve which overload the calls should use. We defer this process to a `_match_overload` method which we will build now.
|
||||
|
||||
=== Matching Overload
|
||||
|
||||
This method receives the list of overloads and all call arguments. Its goal is to:
|
||||
1. identify which overload is compatible
|
||||
2. choose the most specific overload candidate if multiple are plausible
|
||||
|
||||
Regarding the first point, we simply iterate over the overload types and check whether a call with the given arguments is valid. As shown in @fig:overloads-filter-candidates, we first call a method named `_unwrap_function`. This is a simplified form of `get_result`, only to get the actual `Function`. You may find its implementation in @fig:call-dispatcher.
|
||||
|
||||
#codly(
|
||||
range: (104, 135),
|
||||
smart-skip: true
|
||||
)
|
||||
#figure(
|
||||
call-dispatcher-code,
|
||||
caption: [Matching Overload: filtering candidates]
|
||||
) <fig:overloads-filter-candidates>
|
||||
|
||||
After having filtered the overload candidates, if only one remains we can use it as the actual function. If no overload matches the arguments, we directly return an error, as implemented in @fig:overloads-1-0-candidate.
|
||||
|
||||
#codly(
|
||||
range: (143, 154),
|
||||
smart-skip: true
|
||||
)
|
||||
#figure(
|
||||
call-dispatcher-code,
|
||||
caption: [Matching Overload: unique or no candidate]
|
||||
) <fig:overloads-1-0-candidate>
|
||||
|
||||
Finally, if there are multiple candidates, we can try one last thing. If one of the candidates is more specific than all others, it is chosen as the actual function signature. By more specific, we mean that for each argument mapping in one overload, corresponding mappings in all other candidates have supertype parameters. Formally, if $CC$ is the list of valid candidates, $C_"spec"$ the potential most specific candidate and $C_"rest"$ another candidate ($a$ are arguments mapped to parameters $p$):
|
||||
$
|
||||
&exists C_"spec" ({(a_i, p_"spec"_i)^(i in 1..n)}) in CC :\
|
||||
&forall C_"rest" ({(a_j, p_"rest"_j)^(j in 1..n)}) in CC,\
|
||||
&forall i in 1..n, space p_"spec"_i <: p_"rest"_i
|
||||
$
|
||||
|
||||
In Python, this translates to @fig:overloads-candidate-subtype.
|
||||
|
||||
#codly(
|
||||
range: (156, 168),
|
||||
smart-skip: true
|
||||
)
|
||||
#figure(
|
||||
call-dispatcher-code,
|
||||
caption: [Matching Overload: most specific overload]
|
||||
) <fig:overloads-candidate-subtype>
|
||||
|
||||
Ultimately, if this also fails to find a unique, unambiguous candidate, an error is returned, as seen in @fig:overloads-error.
|
||||
|
||||
#codly(
|
||||
range: (170, 176),
|
||||
smart-skip: true
|
||||
)
|
||||
#figure(
|
||||
call-dispatcher-code,
|
||||
caption: [Matching Overload: default error]
|
||||
) <fig:overloads-error>
|
||||
|
||||
Now that we have a method to choose an overload given some arguments, we can add the case in @fig:dispatcher-match-overloaded to `get_result`.
|
||||
|
||||
#codly(
|
||||
range: (35, 44),
|
||||
smart-skip: true
|
||||
)
|
||||
#figure(
|
||||
call-dispatcher-code,
|
||||
caption: [Call Dispatcher: call to `OverloadedFunction`]
|
||||
) <fig:dispatcher-match-overloaded>
|
||||
|
||||
=== Generic function
|
||||
|
||||
Generic functions are useful for defining some kind of template behavior while allowing different types to be used. However, when it comes to a call to such a function, things get a bit more complicated. Indeed, type parameters must be mapped to concrete types and unified depending on the actual call-site arguments before getting the return type. Taking the example of a simple generic doubling function ```py def double(value: T) -> T```, the return type depends on the type of the argument. Furthermore, in a more complex case like ```py def add(v1: T, v2: T) -> T```, the type variable `T` must be mapped to the same type for both `v1` and `v2`.
|
||||
|
||||
This process is covered more in depth in chapter 22 of #acr("TaPL")@tapl.
|
||||
|
||||
For this purpose, we can implement a dedicated `Unifier` class whose role is to find appropriate substitutions for type variables in a generic call. Its source code is available in the repository in #code-ref(<unifier>, "midas/checker/unifier.py").
|
||||
|
||||
Using this class, we can complete `get_result` with the last case handling calls to a `GenericType`, as shown in @fig:dispatcher-match-generic.
|
||||
|
||||
#codly(
|
||||
range: (45, 68),
|
||||
smart-skip: true
|
||||
)
|
||||
#figure(
|
||||
call-dispatcher-code,
|
||||
caption: [Call Dispatcher: call to `GenericType`]
|
||||
) <fig:dispatcher-match-generic>
|
||||
|
||||
== Casts and Static Evaluation <sec:python-check-casts>
|
||||
#todo[]
|
||||
|
||||
190
report/code/call_dispatcher.py
Normal file
190
report/code/call_dispatcher.py
Normal file
@@ -0,0 +1,190 @@
|
||||
class OverloadCandidate:
|
||||
function: Function
|
||||
mapped: list[MappedArgument]
|
||||
|
||||
class CallDispatcher(Generic[E]):
|
||||
def get_result(
|
||||
self,
|
||||
location: Location,
|
||||
callee: Type,
|
||||
positional: list[TypedExpr[E]],
|
||||
keywords: dict[str, TypedExpr[E]],
|
||||
report_errors: bool = True,
|
||||
) -> CallResult:
|
||||
match callee:
|
||||
case Function() as function:
|
||||
valid: bool
|
||||
mapped: list[MappedArgument[E]]
|
||||
valid, mapped = self.map_call_arguments(
|
||||
function, location, positional, keywords
|
||||
)
|
||||
valid = valid and self._are_arguments_valid(mapped, report_errors)
|
||||
if not valid:
|
||||
return CallResult(error=CallError.INVALID_ARGS)
|
||||
return CallResult(result=function.returns)
|
||||
case UnknownType():
|
||||
return CallResult(result=UnknownType())
|
||||
case DerivedType(type=base):
|
||||
return self.get_result(
|
||||
location, base, positional, keywords, report_errors
|
||||
)
|
||||
case AppliedType(body=body):
|
||||
return self.get_result(
|
||||
location, body, positional, keywords, report_errors
|
||||
)
|
||||
case OverloadedFunction(overloads=overloads):
|
||||
res = self._match_overload(
|
||||
overloads, location, positional, keywords, report_errors
|
||||
)
|
||||
if res[0] is None:
|
||||
return CallResult(
|
||||
error=CallError.NO_MATCHING_OVERLOAD,
|
||||
message=res[1],
|
||||
)
|
||||
return CallResult(result=res[0].returns)
|
||||
case GenericType():
|
||||
unifier: Unifier = Unifier(self.types)
|
||||
pos: list[Type] = [a[1] for a in positional]
|
||||
kw: dict[str, Type] = {k: v[1] for k, v in keywords.items()}
|
||||
unified: Optional[Type] = unifier.unify_call(callee, pos, kw)
|
||||
if unified is None:
|
||||
pos_str: str = ", ".join(str(t) for t in pos)
|
||||
kw_str: str = ", ".join(f"{k}: {v}" for k, v in kw.items())
|
||||
message: str = (
|
||||
f"Could not unify {callee}={callee.body} with pos=[{pos_str}] and kw={{{kw_str}}}"
|
||||
)
|
||||
if report_errors:
|
||||
self.reporter.error(location, message)
|
||||
return CallResult(
|
||||
error=CallError.IMPOSSIBLE_UNIFICATION,
|
||||
message=message,
|
||||
)
|
||||
return self.get_result(
|
||||
location,
|
||||
unified,
|
||||
positional,
|
||||
keywords,
|
||||
report_errors,
|
||||
)
|
||||
case _:
|
||||
message: str = f"{callee} ({callee.__class__.__name__}) is not callable"
|
||||
if report_errors:
|
||||
self.reporter.error(location, message)
|
||||
return CallResult(
|
||||
error=CallError.NOT_CALLABLE,
|
||||
message=message,
|
||||
)
|
||||
|
||||
def _unwrap_function(
|
||||
self,
|
||||
callee: Type,
|
||||
positional: list[TypedExpr[E]],
|
||||
keywords: dict[str, TypedExpr[E]],
|
||||
) -> Union[tuple[Function, None], tuple[None, CallError]]:
|
||||
match callee:
|
||||
case Function():
|
||||
return callee, None
|
||||
case DerivedType(type=base):
|
||||
return self._unwrap_function(base, positional, keywords)
|
||||
case AppliedType(body=body):
|
||||
return self._unwrap_function(body, positional, keywords)
|
||||
case GenericType():
|
||||
unifier: Unifier = Unifier(self.types)
|
||||
unified: Optional[Type] = unifier.unify_call(
|
||||
callee,
|
||||
[a[1] for a in positional],
|
||||
{k: v[1] for k, v in keywords.items()},
|
||||
)
|
||||
if unified is None:
|
||||
return None, CallError.IMPOSSIBLE_UNIFICATION
|
||||
return self._unwrap_function(unified, positional, keywords)
|
||||
case _:
|
||||
return None, CallError.NOT_CALLABLE
|
||||
|
||||
def _match_overload(
|
||||
self,
|
||||
overloads: list[Type],
|
||||
location: Location,
|
||||
positional: list[TypedExpr[E]],
|
||||
keywords: dict[str, TypedExpr[E]],
|
||||
report_errors: bool = True,
|
||||
) -> Union[tuple[Function, None], tuple[None, str]]:
|
||||
candidates: list[OverloadCandidate] = []
|
||||
errors: list[CallError] = []
|
||||
for overload in overloads:
|
||||
function, unwrap_error = self._unwrap_function(
|
||||
overload, positional, keywords
|
||||
)
|
||||
if function is None:
|
||||
errors.append(unwrap_error)
|
||||
continue
|
||||
|
||||
valid, mapped = self.map_call_arguments(
|
||||
function=function,
|
||||
location=location,
|
||||
positional=positional,
|
||||
keywords=keywords,
|
||||
report_errors=False,
|
||||
)
|
||||
if valid and self._are_arguments_valid(mapped, report_errors=False):
|
||||
candidates.append(
|
||||
OverloadCandidate(
|
||||
function=function,
|
||||
mapped=mapped,
|
||||
)
|
||||
)
|
||||
|
||||
pos_types: str = ", ".join(str(type) for _, type in positional)
|
||||
kw_types: str = ", ".join(
|
||||
f"{name}: {type}" for name, (_, type) in keywords.items()
|
||||
)
|
||||
for_args: str = f"for arguments pos=[{pos_types}] and kw={{{kw_types}}}"
|
||||
|
||||
n_candidates: int = len(candidates)
|
||||
if n_candidates == 1:
|
||||
return candidates[0].function, None
|
||||
if n_candidates == 0:
|
||||
overloads_str: str = ", ".join(map(str, overloads))
|
||||
errors_str: str = ", ".join(errors)
|
||||
message: str = (
|
||||
f"No matching overload in [{overloads_str}] {for_args} (errors: {errors_str})"
|
||||
)
|
||||
if report_errors:
|
||||
self.reporter.error(location, message)
|
||||
return None, message
|
||||
|
||||
for i1, c1 in enumerate(candidates):
|
||||
mapped1: list[MappedArgument[E]] = c1.mapped
|
||||
best_match: bool = True
|
||||
for i2, c2 in enumerate(candidates):
|
||||
if i1 == i2:
|
||||
continue
|
||||
mapped2: list[MappedArgument[E]] = c2.mapped
|
||||
if not self._are_mapped_subtypes(mapped1, mapped2):
|
||||
best_match = False
|
||||
break
|
||||
self.logger.debug(f"{c1.function} is a full overload of {c2.function}")
|
||||
if best_match:
|
||||
return c1.function, None
|
||||
|
||||
candidates_str: str = ", ".join(
|
||||
str(candidate.function) for candidate in candidates
|
||||
)
|
||||
message: str = f"Multiple matching overloads {for_args}: {candidates_str}"
|
||||
if report_errors:
|
||||
self.reporter.error(location, message)
|
||||
return None, message
|
||||
|
||||
def _are_mapped_subtypes(
|
||||
self, mapped1: list[MappedArgument[E]], mapped2: list[MappedArgument[E]]
|
||||
) -> bool:
|
||||
by_expr: dict[E, Type] = {}
|
||||
for arg in mapped1:
|
||||
by_expr[arg.arg_expr] = arg.parameter.type
|
||||
|
||||
for arg in mapped2:
|
||||
type2: Type = arg.parameter.type
|
||||
type1: Type = by_expr[arg.arg_expr]
|
||||
if not self.types.is_subtype(type1, type2):
|
||||
return False
|
||||
return True
|
||||
Reference in New Issue
Block a user