feat(report): type checking calls

This commit is contained in:
2026-07-20 00:07:53 +02:00
parent fd371d1c51
commit f0edfe5b23
4 changed files with 353 additions and 1 deletions

View 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>

View File

@@ -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>

View File

@@ -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[]

View 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