Skip to content

Instantly share code, notes, and snippets.

@ickc
Created March 3, 2026 15:40
Show Gist options
  • Select an option

  • Save ickc/937210e46b58fe9ba309a5d7c522c3a9 to your computer and use it in GitHub Desktop.

Select an option

Save ickc/937210e46b58fe9ba309a5d7c522c3a9 to your computer and use it in GitHub Desktop.

Revisions

  1. ickc created this gist Mar 3, 2026.
    81 changes: 81 additions & 0 deletions ntuple_covariance.jl
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,81 @@
    # -*- coding: utf-8 -*-
    # %% [markdown]
    # # NTuple invariance vs. tuple covariance
    #
    # Illustrates why `NTuple{N, <:AbstractFloat}` and `NTuple{N, AbstractFloat}`
    # mean very different things.

    # %%
    # --- helper ---
    check(label, val) = println(label, ": ", val)

    # %% [markdown]
    # ## 1. `NTuple{N, <:AbstractFloat}` — invariant TypeVar
    #
    # `NTuple{2, <:AbstractFloat}` expands to `NTuple{2, T} where T <: AbstractFloat`.
    # Julia must solve `T` to a **single** concrete type that satisfies every element,
    # so both elements must share exactly the same type.

    # %%
    # Both Float64 → T = Float64 ✓
    check("(1.0, 2.0) <: NTuple{2,<:AbstractFloat}",
    (1.0, 2.0) isa NTuple{2, <:AbstractFloat})

    # Both Float32 → T = Float32 ✓
    check("(1f0, 2f0) <: NTuple{2,<:AbstractFloat}",
    (1.0f0, 2.0f0) isa NTuple{2, <:AbstractFloat})

    # Float64 + Float32 → no single T satisfies both ✗
    check("(1.0, 1f0) <: NTuple{2,<:AbstractFloat}",
    (1.0, 1.0f0) isa NTuple{2, <:AbstractFloat})

    # %% [markdown]
    # ## 2. `NTuple{N, AbstractFloat}` — covariant tuple
    #
    # `NTuple{2, AbstractFloat}` means `Tuple{AbstractFloat, AbstractFloat}`.
    # Julia tuples are **covariant**: `Tuple{A,B} <: Tuple{P,Q}` holds whenever
    # `A <: P` and `B <: Q` are satisfied *independently* for each position.
    # `A` and `B` do **not** need to be the same type.

    # %%
    # Both Float64 → Float64 <: AbstractFloat ✓
    check("(1.0, 2.0) <: NTuple{2,AbstractFloat}",
    (1.0, 2.0) isa NTuple{2, AbstractFloat})

    # Float64 + Float32 → each position checked independently ✓
    check("(1.0, 1f0) <: NTuple{2,AbstractFloat}",
    (1.0, 1.0f0) isa NTuple{2, AbstractFloat})

    # Float64 + Int (not <: AbstractFloat) ✗
    check("(1.0, 1) <: NTuple{2,AbstractFloat}",
    (1.0, 1) isa NTuple{2, AbstractFloat})

    # %% [markdown]
    # ## 3. Type-level check (dispatch)
    #
    # The same distinction appears in method dispatch / `<:` on types.

    # %%
    # NTuple{2,<:AbstractFloat} requires a common T
    check("Tuple{Float64,Float64} <: NTuple{2,<:AbstractFloat}",
    Tuple{Float64, Float64} <: NTuple{2, <:AbstractFloat})

    check("Tuple{Float64,Float32} <: NTuple{2,<:AbstractFloat}",
    Tuple{Float64, Float32} <: NTuple{2, <:AbstractFloat})

    println()

    # NTuple{2,AbstractFloat} only needs each position to be <: AbstractFloat
    check("Tuple{Float64,Float64} <: NTuple{2,AbstractFloat}",
    Tuple{Float64, Float64} <: NTuple{2, AbstractFloat})

    check("Tuple{Float64,Float32} <: NTuple{2,AbstractFloat}",
    Tuple{Float64, Float32} <: NTuple{2, AbstractFloat})

    # %% [markdown]
    # ### Summary
    #
    # | Syntax | Constraint |
    # |--------|-----------|
    # | `NTuple{N, <:AbstractFloat}` | all N elements must be the **same** subtype T |
    # | `NTuple{N, AbstractFloat}` | each element only needs to satisfy `<: AbstractFloat` independently |