13
$\begingroup$

Many languages that has static typing or type hinting, support user-defined casting functions that are called automatically when a function expects another type that the parameter could be cast into.

But I think most of them don't support this kind of implicit casting across multiple levels. That is, if the user has only defined the casting from A to B, and from B to C, but called a function that expects a parameter in type C with a parameter of type A, without writing the type conversions explicitly, it usually wouldn't compile.

Supporting this by default indeed sounds like a horrible idea. But with some obvious patches such as precedence and directives to block indirect casting, it's not like it is inherently evil.

If it actually works, it would allow using types much more freely.

What are the pitfalls? Do they make indirect implicit casting really infeasible?

This question is about user-defined casting. Casting to base classes across multiple levels is usually not a problem, but it is more like a special case.

$\endgroup$
7
  • 1
    $\begingroup$ My use case is the unsafe string thing in another question. A bit confusingly, sometimes it might be helpful to cast from strings to unsafe strings by escaping, but functions expecting unsafe strings would be more difficult to use without indirect implicit casting, if there were many other types that could be cast into strings. $\endgroup$
    – user23013
    Jul 24, 2023 at 19:32
  • 1
    $\begingroup$ I have a mental model with sort of two types of implicit casts: you could perhaps call them "true" casts and "convenience" casts. If the two sides of the cast are truly interchangeable, chain all you like. For example, automatic dereferencing in Rust will (I think) do an arbitrary number of conversions. This is fine, since the conversion is sort of a formality; it does not change meaning. But you wouldn't want to do this with "convenience" casts, like turning integers into strings. $\endgroup$ Jul 25, 2023 at 17:39
  • 1
    $\begingroup$ FYI in C# we considered this question and decided that when evaluating a user defined conversion, the compiler will automatically insert built-in conversions on "either side" of the user-defined conversion, but will never "chain" user-defined conversions. $\endgroup$ Jul 26, 2023 at 0:13
  • 1
    $\begingroup$ For example, if you have a user-defined explicit conversion from non-nullable struct S to decimal?, say, then you can cast S? to int and the compiler will do S? -> S -> decimal? -> decimal -> int, all of which are explicit conversions but only one of which is user-defined. That led to some complexity (that is, weird bugs!) in the compiler but it avoided some of the weird cases described in answers. $\endgroup$ Jul 26, 2023 at 0:16
  • 1
    $\begingroup$ For frame challenge answers: Pros and cons of user-defined implicit casting. $\endgroup$
    – user23013
    Jul 26, 2023 at 6:21

4 Answers 4

19
$\begingroup$

Ambiguity and its Consequences

One immediate downside is that the chain of casts may be ambiguous. For example, suppose that we have types AF and the following implicit conversions available:

  • A can implicitly become B or C.
  • Both B and C can implicitly become D or E.
  • Both D and E can implicitly become F.

Then there are four chains of conversion that the compiler/interpreter needs to decide among:

  • ABDF
  • ABEF
  • ACDF
  • ACEF

Ways to resolve such ambiguities fall on a spectrum from fixed policy to wholly programmer-disambiguated, but no point on the spectrum is very satisfactory.

Fixed Policy

Suppose that we use a fixed policy to resolve the conversion path. Now we have at least four problems:

  • Like with any overzealous DWIM feature, the first problem is that the policy could choose a path other than the one the programmer intended, and it's hard to see how the programmer could be warned about that until the bug causes problems at runtime.
  • Another huge downside is the fact that introducing a new implicit cast can have side effects in code unrelated to either the source or destination type. Take the example above, suppose that an AG implicit cast is already possible, and consider what happens when we introduce a GE implicit cast: we create the path AGEF, which the policy may select as the new way to convert from A to F even though neither of those types were mentioned in the code we added. That makes it difficult for programmers to predict what their code changes will do.
  • Related to the prior problem, unless we have a really good way to limit which implicit casts are in scope, the compiler/interpreter might have to search a lot of code (the whole program if we place no restrictions) just to enumerate the potential paths. That raises a lot of issues for separate compilation, dynamic loading, etc.
  • If we keep adding intermediate steps with multiple options like in the example above, the number of candidate paths can be exponential in the program size. That severely limits our choices for efficiently implementable disambiguation policies, mostly to greedy or bounded-lookahead strategies.

Programmer-Defined Policy

Now consider the other extreme, where we allow the programmer to disambiguate. At first glance, this seems like a win, since, e.g., they could specify the conversion path from A to F once instead of having to write a cast everywhere they need the conversion. But, of course, they can already do that without indirect implicit casting: they would just write a new implicit cast from A to F defined as calling one intermediate cast on the result of another. So at this end of a spectrum we've added a new language feature only to replicate what was probably already possible with direct implicit casts. (One exception might be languages where it is difficult to add new implicit casts between externally defined types, but then it's better to address that shortcoming directly.)

In languages that have suitably powerful templates or generics or similar features, it's even possible to write rules that apply to many types in this way. For instance, with your example about passing a non-string value to a function that uses the type system to require a properly escaped string, in a suitable language we could define a templated implicit cast from any stringifiable type T to escaped strings where the body just escapes whatever implicit stringification returns.

Mixed Policy

In the middle, we could have a default policy and allow the user to make tweaks, but then we just combine downsides: it remains easy to write bugs silently, we couple implicit casts with all kinds of unrelated code, we're limited in the quality of the defaults we can compute efficiently, and in the end we might not save the programmer any effort.

$\endgroup$
3
  • $\begingroup$ Ambiguities will also show up in Type Inference. In Java there's not much of it -- just var -- but in some languages most local types are fully inferred -- saving quite a bit of typing -- which... would probably not work well with any ambiguity. Languages where everything is an Object already suffer from this, as suddenly a list where you insert a string and an integer can be inferred to be a list of Objects... and the developer is left wondering why oh why. $\endgroup$ Jul 25, 2023 at 13:30
  • $\begingroup$ @MatthieuM. I don't understand why the developer would be left wondering; if you have a list with a string and an integer, you have a List[Union(String, Integer)]; since Java doesn't have union types (generally only functional languages do), it's a List[Object], like any Lisp or Python programmer would expect. $\endgroup$
    – prosfilaes
    Jul 25, 2023 at 17:38
  • 1
    $\begingroup$ @prosfilaes: If it's intentional, there's no issue. If it's not... and the compiler complains that the user is attempting to pass/return a List[Object] where a List[String] is expected, then the user is left wondering why the hell the compiler is so stupid, since it's obviously a List[String], and they will have to scrutinize all uses of the List to spot the one place in their code where actually they inserted an Integer by mistake. $\endgroup$ Jul 26, 2023 at 9:55
25
$\begingroup$

Type checking is dramatically less effective

The main goal of type checking is to notice bugs before they happen, and to present them explicitly as bugs instead of causing the program to continue running, but with very weird and unexpected behavior.

The problem with long chains of indirect casts is that they mean the compiler is very often unable to detect bugs, since passing even a completely unrelated type in place of another could be valid through some long chain of (individually reasonable) implicit casts.

Take, for example, the following rules:

  • A number can be cast to a string
  • A string can be cast to a char array
  • An array can be cast to a set

These chain together to mean that a number and a set are interchangeable. For example, if you were to pass the number 2181012 to a function expecting a set, you could end up with {0, 1, 2, 8}, and no warnings or errors.

Most chains of 2+ casts, with a few exceptions like autoboxing pointers, are semantically meaningless (e.g., think about a Date struct being implicitly cast to a UNIX timestamp, which is implicitly cast to a Price struct containing dollars and cents), and are quite likely to be bugs.

$\endgroup$
5
  • $\begingroup$ Languages like C# mitigate this by allowing you to have implicit and explicit casts, so a number could have explicit operator string() and break this implicit triple-cast. Of course, I don't think C# has implicit triple-casts in the first place. $\endgroup$
    – Bbrk24
    Jul 24, 2023 at 19:12
  • $\begingroup$ There are languages like C++ that supports none of the 3 conversions. So I think it might be still manageable to just mark them as the worse type of conversions and disallow only them for indirect casting. $\endgroup$
    – user23013
    Jul 24, 2023 at 19:33
  • $\begingroup$ @user23013 That's true I guess; if the implicit casts are more conservative and sensible, this wouldn't be as big of a factor. $\endgroup$ Jul 24, 2023 at 20:01
  • 7
    $\begingroup$ Another type-checking related problem is that method overloading would become a lot more complicated. Even if there's no ambiguity to the compiler, there would probably be cases that catch the programmer unawares. $\endgroup$
    – user
    Jul 24, 2023 at 23:13
  • $\begingroup$ I found number to string to be dodgy automatic cast in the first place. Casting a string to a set is something I actually want to do occasionally. $\endgroup$
    – Joshua
    Jul 27, 2023 at 18:15
3
$\begingroup$

TL;DR: Don't offer implicit casts at all (besides trivial, reliable ones that you define in your language specification, e.g. casts from int8 to int32).

(Disclaimer: I am an old-school Java enthusiast, so my answer might be biased.)

Language Clarity

I prefer a language to be clear, meaning that everything that happens (1) is visible in the source code. And when knowing a language, I can always tell where to look for the "contract" of that element: either in the language specification, or in an easily-found place in user or library code.

E.g. if I see an assignment opererator, I like its semantics to be well-defined in the language specification, without the need to search (or navigate) through source code and libraries to grasp its meaning.

IDE Friendliness

If every non-trivial action is found explicitly in the source code, it not only makes the code clear, it also gives your IDE a logical place from where to navigate to the relevant definition.

If you have hidden, non-trivial casting (no matter whether single or multiple steps), you see a variable of type B being assigned an expression of type A, and nothing in the source code that represents the conversion. Only a sophisticated IDE can give you a hint that there is more than just an assignment going on.

Even for this IDE it's non-trivial to provide a navigation point where to offer the "goto definition" action. In an assignment statement, it might choose the = operator, which might already be overloaded depending on your language features, so it needs a submenu, offering the overloaded assignment operator and the various cast implementations involved here.

Probably, you'll also allow implicit casting not only for assignments, but also within expressions, e.g. in method call arguments. Where do you expect the IDE to offer the navigation point then?

Ambiguity and Complexity

Others have already discussed that in-depth, so there's no need to repeat those aspects here.

But I'd like to mention one additional pitfall. If you write

A a;
B b = a;

This calls the A-to-B cast. If now, a developer introduces a superclass (or interface, supertype or whatever you might call it in your language) of B, named SB, and follows the good practice to declare a variable with the most generic type that still matches the requirements:

A a;
SB b = a;

Then another cast, being A-to-SB, appears as candidate, and probably a few others if SB has more than one subclass. Now you need a disambiguation rule which one to choose, and it's very likely that it will no longer result in the A-to-B cast. So, by just following a good practice, this developer broke the program.

Questionable Benefit

Evrything you can do with implicit casts, you can as well do with explicit ones. It only saves you a few characters. IMHO these few characters aren't worth the pain.


(1) What counts as "happens" is subject to interpretation.

Is treating an int8 as int32 something that matters enough to become a visible action? You can argue that it's now a longer bit string, containing 32 instead of 8 bits, but you can also argue that a number like e.g. 96 stays the same, no matter whether you store it as an 8-bit or a 32-bit pattern. Many existing languages do implicit casts with this type of conversion, and force you to write explicit casts only if there is a risk of losing some information.

If the conversion involves user code, then probably everybody will agree that there is something "happening", and then I like that to be explicitly visible.

$\endgroup$
4
  • $\begingroup$ "follows the good practice to declare a variable with the most generic type that still matches the requirements" - one of the requirements of b's type is "can be constructed from an A with the correct semantics". $\endgroup$ Jul 25, 2023 at 13:56
  • 1
    $\begingroup$ If 1+2 has a type, say int8, then "int64 x = 1 + 2;" is illegal without an implicit conversion. (If it's boosted to int64, then "int8 x = 1 + 2;" is illegal; and int8 -> int64 is a safe conversion, while int64 -> int8 is definitely not.) Unless you have but one integer type, presumably either the largest hardware supported type or an unbounded integer (i.e. the most expensive choice), implicit casts are going to be widespread and noisy. $\endgroup$
    – prosfilaes
    Jul 25, 2023 at 17:45
  • 1
    $\begingroup$ I've written a program for a fairy chess game with 8x12 boards in Ada, which lets you define distinct types, row = 1..8 and column = 1..12. That prevented some bugs, but required bunches of noisy casts; arguably, the loss in readability cost more in bug prevention than the strong typing gave. Even in Ada, which is pretty strict typing, that was optional, and that seems pretty clearly the right choice. $\endgroup$
    – prosfilaes
    Jul 25, 2023 at 18:25
  • $\begingroup$ I see, I should have been clearer in the wording of my answer, as I do support implicit conversions of e.g. different-sized integers - casts that are defined in the language specification, not by some user code. I'll edit my answer. $\endgroup$ Jul 26, 2023 at 7:59
2
$\begingroup$

Frame challenge: The question assumes that implicit casting is okay.

The biggest problem I see with supporting indirect implicit casting is that you are supporting implicit casting. Don't do it, every typed language that supports implicit casting has come to regret it.

Implicit casts create difficult to track down bugs. Which is why lint has a setting to treat implicit casts as errors. And why coding standards usually have a "all casts must be explicit" line somewhere in them.

Rust which was designed to fix many of the security flaws/foot self-perforation of C/C++ while allowing for the same performance/low level control, does not allow implicit casting.

TLDR: Don't support implicit casts, meaningful verbosity makes code clearer.

$\endgroup$
2
  • $\begingroup$ Rust does allow chained automatic dereferences, though, which I claim are a type of implicit cast. So it's not as simple as that. It's about which casts make sense to give this treatment. $\endgroup$ Jul 25, 2023 at 17:40
  • 3
    $\begingroup$ Rust does allow implicit type conversions in some contexts. Notably, the ? operator (which either unwraps an Ok result or otherwise returns early from the containing function with an Err result) implicitly converts the error value to the expected type, if an implementation of the Into trait is available. There are also implicit conversions between between types like &&T and &T, and from arrays to slices. See here and here for more information. $\endgroup$
    – kaya3
    Jul 25, 2023 at 18:41

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .