17
$\begingroup$

In many languages, variable shadowing is restricted to various degrees, or not at all. To me, unrestricted variable shadowing seems like a classic "footgun" in that, while a savvy programmer isn't likely to cause problems for themselves or others with it, it can easily lead to readability issues as well as confusion in actually writing further code when used hastily. I thought to ask this question after taking several minutes to read code which shadowed a function argument about five indents deep, then used the shadowing variable down another four or so--clearly a problem of its own, but one that may have been addressed if the compiler complained about it. I specify "in the general case" because there are cases in which shadowing is "obviously" desirable:

  • Function-internal names shadowing exported names. Naming things is hard, and you definitely want to have good names for what you expose, but that shouldn't have to get in the way of the innards, especially if there's a qualified-name mechanism for accessing the shadowed stuff on the off chance you need it anyhow. This can arguably still create readability concerns, but you also don't want to have to resolve conflicts with internal variable names when you add new exposed functionality.
  • It's conceptually "the same thing". Specifically, either the shadowed variable is downcast (hopefully conditionally) to the shadowing variable, or the shadowing variable is extracted from some kind of optional/nullable/monadic-error type. This could either be allowed explicitly by adding some kind of structural constraint on the right-hand side of a shadowing initialization, by subsuming it into a dedicated language feature like Kotlin's smart casts or Swift's bare if let, or by encouraging the use of functional constructs alongside the next bullet point.
  • Inner function parameters. Even ignoring the case of a language where inner functions are not closures, inner functions can often work with similar concepts to but different data than what's using them, and when all of the shadowing risks are collected in one place for a probably-not-too-large scope, it ought to be harmless if not strictly necessary.

Additionally, there's the borderline case of argument preprocessing, where some transformation is applied to an argument of a function at or near the top of the function body--whether it's to apply a default value, normalize some property, etc. This feels like a problem best solved with more descriptive variable names and/or moving logic into internal helper functions, but overly long variable names can be clunky to throw around, and internal helper functions seem to generally not be considered idiomatic in languages that don't have features to actively encourage them, like Haskell's where clauses.

In a sense, this is really asking about if these exceptions are adequate to permit all "legitimate" shadowing, and how they intersect with other facets of language design. Nevertheless, what are the simple pros and cons of general variable shadowing?

$\endgroup$
2
  • 1
    $\begingroup$ I think this question has a frame of reference of a particular style of language that it never makes explicit and that makes it hard to answer. What a variable is, what it means to shadow, what you conceptually can and can't shadow, etc, vary wildly between different paradigms (object fields and methods?) and even within (Lisp-1 vs Lisp-2), and the implications of allowing it also vary. It would be good to set out what it's thinking about and considering in and out of scope in the top of the question itself. $\endgroup$
    – Michael Homer
    May 17, 2023 at 5:39
  • $\begingroup$ @MichaelHomer I've tried to divorce the question from any particular style of language and lead answers to sort of fill in how other choices bear on it, but that does make answering demanding, and there are still some unstated assumptions. I'll see if I can drag those out. $\endgroup$ May 17, 2023 at 5:48

3 Answers 3

17
$\begingroup$

Actually banning name shadowing uniformly can be hard, and it constrains what else you can do in the language, sometimes in places far away from where the prohibition really happens. It's generally viable in a functional model where names have no external significance, but it poses a number of problems in systems with objects or similar data types (even ADTs), where a certain level of shadowing may be required — and so the question becomes how much and where.

Let's follow a real-world case study of a system that really tried to prohibit all name shadowing, and had to dial it back for practical reasons. We'll circle back at the end to what the takeaway from that is, but in short: it's one choice among many, and they're all tradeoffs with their own costs.


No-shadowing in Grace

I worked on an object-oriented language called Grace during my PhD that started out with a firm no-shadowing rule: no defined name could also exist in a surrounding scope. The idea (not mine) behind that rule was to ensure that it was always clear to the user (presumptively a novice) what a particular name referred to. Every declared name was part of that, because lexical scoping could pull in local variables, fields, or methods from surrounding scopes. It started out really enforcing that across the board, and I implemented the strict checker it needed.

One problem came up pretty quickly: you can nest object literals and classes inside one another - so the inner object's fields aren't allowed to shadow those of the outer object. That's a problem, though, because method and field names are part of the type of the object - you really do need to be able to give arbitrary names at any level, because you want to be able to write and return an object literal of the same type as its surrounding object. So, ok, we'll permit that as an exception.

type Copyable = type { copy -> Copyable }
def x : Copyable = object {
    method copy -> Copyable {
        return object {
            method copy -> Copyable { return self }
        }
    }
}

Another follows afterwards: there's inheritance in the language, and you can access inherited fields and methods unqualified. If you can't shadow an inherited name, adding a new field to a superclass can break unrelated code inside its children! So, fine, we'll carve that situation out too.

But, in fact, it's worse. Consider this case:

class A {
    var x := 1
}
class B {
    var x := 1
}
class C inherits A {
    class D inherits B {}
}

Class D, containing no code at all, has a shadowing violation! It's got one x in its surrounding scope, and one x from its own parent, and it can't do anything about either of them. We can define this as not a problem and unambiguously pick the inner class's parent's version if the code says x, but in doing so we're cutting back on the purpose of the no-shadowing rule in the first place.

Ultimately, the no-shadowing requirement was restricted to local variables and parameters only: you can't declare a method-scope variable with the same name as something outside. This all started from the position of really trying to push this one-and-only-one concept as far as it could go and minimising the carveouts, but couldn't sustain it any further than that without losing too much.


The takeaway

All of these problems are avoidable with different language choices: don't allow unqualified access to fields, don't apply the Uniform Access Principle so that fields and methods are distinct namespaces, only look up names lexically and not in the inheritance chain, permit renaming and exclusion during inheritance, don't have inheritance, don't have objects, and so on.

All of those are fair decisions to make, but they make for a different language, with different ergonomics and a different set of trade-offs made. Relaxing the no-shadowing rule almost away entirely preserved other traits that were seen as more important.

Is the remaining rule worthwhile? Yes, probably, given the audience, but it has costs too. Limiting the class of errors you identify is helpful for this use case, though there are still cases where I'd probably like to be able to reuse a good name that's been obsoleted by that point in the code.

There is a cognitive burden in picking new names and handling the error messages when you didn't, and we shouldn't be too dismissive of that either. A more total rule would have less burden in remembering where you have to follow it, and more of all the other issues; the language designer's job is to balance those costs.

$\endgroup$
1
  • 5
    $\begingroup$ I started reading this answer expecting to disagree with its conclusions, but the last part ─ "All of these problems are avoidable with different language choices ... but they make for a different language" ─ addressed all of my thoughts perfectly. The A/B/C/D example is also a really good one, though perhaps this shows that there's a need to distinguish between shadowing where one declaration lexically "dominates" another vs. shadowing where two different declarations of the same name are pulled into the same scope via other mechanisms such as inheritance or wildcard imports. Good answer! $\endgroup$
    – kaya3
    May 17, 2023 at 9:26
9
$\begingroup$

Shadowing is a useful feature for various reasons, as you mention in your question.

There is one practical upside ─ simplicity of implementation ─ to banning shadowing altogether, so that there is a one-to-one mapping between names as strings, and "symbols" or declared names. A compiler which bans shadowing can perform name resolution with just a single dictionary of names that are in scope at any given point, adding them as they are declared and removing them as they go out of scope, with no need to recover the shadowed names.

On the other hand, all of the downsides of shadowing are in the greyer area of "this program isn't malformed but it's potentially harder to understand, or there's a decent chance it's a mistake". Certainly some things like this are worthy of compiler errors, but most aren't; experienced programmers who use shadowing intentionally and not by mistake will be annoyed if the compiler stops them from doing it in some circumstances but not others, particularly because the language's semantics don't preclude shadowing in well-formed programs.

Additionally, if a language completely forbids shadowing, then any time a new name is added into an outer scope (e.g. by an update to the standard library), this can cause previously-valid programs to break, simply because the same name was in use somewhere more deeply nested. In the worst case, the more-deeply-nested name might be a private member of a subclass, which isn't even in the same source file; then the parent class must avoid using names which would cause errors in subclasses, but it isn't even known which names are forbidden.

So this kind of "error" or warning is more in the domain of a linter, which warns about stylistic mistakes, readability issues, and potential logic errors in otherwise well-formed programs. It's the user's choice whether to use a linter and, if so, which lint rules to enable or disable. So I would recommend leaving such opinionated rules to the linter, and in your compiler focus instead on rules entailed by the language's semantics rather than the author's discretion.

$\endgroup$
4
  • $\begingroup$ The possibility of breakage from external symbols can be mitigated in some languages by making the set of valid local symbols disjoint from the set of global symbols (e.g. requiring that local symbols start or end with a certain punctuation character), or having a convention that distinguishes them (e.g. following a convention where all global symbols start with an uppercase letter, and no local symbols do so). $\endgroup$
    – supercat
    Nov 10, 2023 at 18:42
  • $\begingroup$ @supercat That works if there are only two levels of locality, but I don't see how you can do this for e.g. a block-scoped variable shadowing a local variable from the outer block, a local variable in a closure shadowing a local variable of the outer function, or a field of a subclass shadowing a field of the superclass. $\endgroup$
    – kaya3
    Nov 10, 2023 at 19:01
  • $\begingroup$ Block scopes within a function don't really pose a compatibility issue, since anyone changing part of the code should have access to the whole thing. Nested class members could be handled by a convention of prefixing members with something related to the class name or hierarchy. $\endgroup$
    – supercat
    Nov 10, 2023 at 19:54
  • $\begingroup$ Another general approach languages can take is to say that certain kinds of potentially ambiguous constructs will default to being rejected, but may via directives or compilation options be biased toward one or the other treatment. For example, compilers could be configurable so that double1 = float1*float2; would be treated as double1 = (float)(float1*float2); or as double1 = (double)float1 * (double)float2;, without having to be written in one of the latter forms, but by default the construct would be rejected, and the latter forms would generally be preferred in any case. $\endgroup$
    – supercat
    Nov 10, 2023 at 21:10
3
$\begingroup$

"Pros & cons of shadowing" seems to imply that there are only two choices, but allowing or disallowing shadowing are not the only two design options here. The interesting part is how to allow it.

Let's consider specifically the first problem mentioned in the question: readability and confusion of shadowed variables. A bug is introduced that would have been caught by disallowing shadowing. This problem can be mitigated in a few ways.

  • Require the presence of a keyword (like shadow, or hiding) at the declaration of the shadowing variable. This keyword being relatively rare (most variables don't shadow), it would stand out and remind the reader to be careful.

    Additionally, it would prevent the shadowing from being accidental when writing the code, because the compiler would complain that we didn't add the keyword. Furthermore, we could require the declaration of the shadowed variable to be tagged by that keyword too, making it even more unmissable that some shadowing is going on.

  • IDE support for per-variable coloration. Assign different colors to the shadowing and shadowed variables, making it visually evident everywhere which one is being referred to. (This exists in Jetbrains IntelliJ IDEA, and Kotlin smart casts get a different color.) A related feature, automatic highlighting of all uses of the currently selected variable, also helps.

    This second point can be considered IDE design rather than language design, but I believe the two should not be considered independently. Trying to prevent bugs, a main goal of language design, is an ergonomics issue with a larger scope that language itself.

Of course, variables are only one instance of the shadowing question in language design. For other cases, other mitigations might be invented too.

$\endgroup$
3
  • 1
    $\begingroup$ You also have the situation in C++ that within a member function, “x” could be a member of *this or a plain variable. Imagine you have a large function that uses x meaning this->x close to the end. Two years later we change y = (t+z) * (t+z); to int x = t+ z; y=x * x; and everything breaks. $\endgroup$
    – gnasher729
    Nov 8, 2023 at 16:59
  • 1
    $\begingroup$ Or static and global variables $\endgroup$
    – gnasher729
    Nov 8, 2023 at 17:07
  • $\begingroup$ Another approach would be to specify that names from outer contexts are only brought into inner scopes that expressly import them. So if a class inherits another class that has fields x, y, and buttercup, but only lists x and y as imported fields, then the derived object could have a field buffercup, but only if it both defines that field itself and refrains from specifying it as an import. $\endgroup$
    – supercat
    Nov 8, 2023 at 21:32

You must log in to answer this question.

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