Entendiendo el Compilador de Go: El Comprobador de Tipos

📚 Entendiendo el Compilador de Go (3 of 4)
  1. 1. El Scanner
  2. 2. El Parser
  3. 3. El Comprobador de Tipos Estás aquí
  4. 4. El Formato Unified IR
El Comprobador de Tipos

En los artículos anteriores, exploramos el scanner—que convierte código fuente en tokens—y el parser—que toma esos tokens y construye un Árbol de Sintaxis Abstracta.

En artículos futuros, cubriré la Representación Intermedia (IR)—cómo el compilador transforma el AST en una forma intermedia más low level. Pero antes de que podamos llegar ahí, necesitamos hablar sobre dos pasos intermedios cruciales: verificación de tipos (este artículo) y el Unified IR (que cubriré en un artículo separado pronto).

Podrías preguntarte: ¿por qué no podemos ir directamente del AST al IR? Aquí está la cosa—el AST es solo estructura. Nos dice que fmt.Println("Hello world") es una llamada a función con un argumento, pero no nos dice si fmt es un paquete válido, si Println realmente existe, o si el argumento de cadena es del tipo correcto. El AST no tiene idea si tu código tiene sentido.

Ahí es donde entra el comprobador de tipos. Su trabajo es verificar que tu programa es válido. Resuelve nombres (¿a qué se refiere fmt?), verifica tipos (¿esta función acepta una cadena?), y asegura que todas las reglas del sistema de tipos de Go se siguen. Solo después de que el código pasa la verificación de tipos puede el compilador transformarlo de forma segura en IR.

Déjame mostrarte qué significa eso realmente en la práctica.

¿Qué Hace Realmente la Verificación de Tipos?

La verificación de tipos es más que solo verificar tipos. Es una fase de validación completa que maneja varias tareas críticas.

Primero, resuelve identificadores—conectando cada nombre a su declaración. Cuando escribes fmt, el comprobador de tipos averigua que es el paquete que importaste. Cuando escribes Println, busca ese nombre en el paquete fmt y confirma que existe.

Segundo, verifica tipos—asegurándose de que las operaciones son válidas para sus tipos. ¿Puedes sumar una cadena a un int? No, y el comprobador de tipos te lo dirá. ¿Puedes pasar una cadena a una función que espera un io.Reader? Tampoco.

Tercero, valida la inicialización—averiguando el orden para inicializar variables a nivel de paquete sin crear ciclos. Si la variable a depende de b, entonces b necesita ser inicializada primero.

Cuarto, maneja genéricos—instanciando tipos genéricos con argumentos de tipo concretos y validando que esos tipos satisfacen las restricciones.

Y finalmente, detecta errores—reportando tipos incompatibles, nombres indefinidos, operaciones inválidas y más.

Piensa en el comprobador de tipos como el verificador de hechos de tu código. El parser ya confirmó que tu código tiene estructura válida, pero ahora el comprobador de tipos pregunta: “¿Esto realmente tiene sentido según las reglas de Go?”

Veamos qué sucede cuando verificamos tipos de nuestro programa hola mundo:

package main

import "fmt"

func main() {
    fmt.Println("Hello world")
}

El comprobador de tipos necesita responder varias preguntas:

  • ¿Es main un nombre de paquete válido? ✓
  • ¿Existe el paquete fmt? ✓
  • ¿Es Println una función en fmt? ✓
  • ¿Puede Println aceptar un argumento de cadena? ✓
  • ¿Es main un punto de entrada válido (firma correcta)? ✓

Solo después de que todas estas verificaciones pasan, el compilador avanza.

Ahora, responder todas esas preguntas no es tan simple como hacer un pase a través del código. El comprobador de tipos necesita una estrategia sofisticada para manejar la complejidad de Go.

Visión general: Fases de Verificación de Tipos

El comprobador de tipos de Go no es un solo pase. Es un proceso multi-fase cuidadosamente orquestado que maneja la complejidad de Go—referencias hacia adelante, tipos recursivos, dependencias de inicialización y genéricos.

Aquí está el flujo básico: el comprobador de tipos primero valida la estructura del paquete (initFiles), luego escanea todas las declaraciones para crear marcadores de posición (collectObjects), verifica tipos de esas declaraciones pero omite cuerpos de función (packageObjects), y luego regresa para verificar los cuerpos de función (processDelayed). Después de eso, hay algunas fases de limpieza para manejar cosas como orden de inicialización e importaciones no usadas.

¿Por qué tantas fases? Porque Go permite referencias hacia adelante y tipos recursivos—puedes referenciar algo antes de que sea declarado, y el compilador necesita manejar eso correctamente.

Recorramos las primeras cuatro fases en detalle—esas son donde ocurre el trabajo interesante—usando nuestro programa hola mundo para ver exactamente qué hace el comprobador de tipos en cada paso.

Fase 1: Inicialización y Configuración

El comprobador de tipos comienza en src/cmd/compile/internal/types2/check.go:468. Crea un objeto Checker—la máquina de estado principal para verificación de tipos—y comienza a trabajar a través de las fases.

Primero está initFiles, que valida la estructura del paquete:

func (check *Checker) initFiles(files []*syntax.File) {
    // Verifica que todos los archivos tienen el mismo nombre de paquete
    // Extrae versión de Go de etiquetas de compilación
    // Almacena estado específico de archivo
}

Para nuestro programa hola mundo, esta fase:

  • Confirma que el archivo declara package main
  • Registra la versión de Go (de etiquetas de compilación o predeterminada)
  • Restablece el estado interno para una ejecución de verificación de tipos desde cero

Nada demasiado emocionante aquí—solo asegurándose de que los básicos están en orden. Una vez que la configuración está completa, el comprobador de tipos puede pasar a trabajo más interesante.

Fase 2: Recopilando Objetos

Con la inicialización hecha, el comprobador de tipos entra en collectObjects (src/cmd/compile/internal/types2/resolver.go:202). Esta fase escanea a través del AST y crea objetos para cada declaración—constantes, variables, tipos y funciones—sin verificarlos aún.

Piensa en esto como hacer inventario. El comprobador de tipos camina a través del AST completo y dice: “Veo una importación aquí, una función allá, un tipo por ahí.” Crea objetos marcadores de posición para todo y los almacena en un mapa.

Esto es lo que sucede con nuestro hola mundo:

Declaración de importación:

case *syntax.ImportDecl:
    // Valida ruta de importación
    path, err := validatedImportPath(s.Path.Value)

    // Importa el paquete
    imp := check.importPackage(s.Path.Pos(), path, fileDir)

El comprobador de tipos ve import "fmt", valida la ruta, y usa el Importer (configurado durante la configuración) para cargar el paquete fmt. Esto da al comprobador de tipos acceso a los nombres exportados de fmt—incluyendo Println. (Exploraremos cómo el compilador lee y procesa estas definiciones exportadas en el artículo del Unified IR.)

Declaración de función:

case *syntax.FuncDecl:
    // Crea objeto Func
    f := NewFunc(d.Pos(), pkg, name.Value, nil)
    check.objMap[f] = &declInfo{
        file:  fileScope,
        fdecl: d,
    }

El comprobador de tipos ve func main(), crea un objeto Func para ella, y lo almacena en objMap. Nota que aún no verifica el cuerpo de la función—solo registra “hay una función llamada main” y continúa.

Al final de esta fase, el comprobador de tipos tiene un inventario completo de declaraciones pero no ha validado ninguno de sus detalles. Ahora que sabemos qué existe, podemos comenzar a verificar si realmente es válido.

Fase 3: Verificando Tipos de Declaraciones

Aquí es donde las cosas se ponen interesantes. packageObjects (src/cmd/compile/internal/types2/resolver.go:628) es donde comienza la verificación de tipos real—pero solo para declaraciones a nivel de paquete. Los cuerpos de función todavía se omiten.

El comprobador de tipos procesa declaraciones en tres sub-fases:

  1. Declaraciones de tipo no-alias
  2. Declaraciones de tipo alias
  3. Todo lo demás (constantes, variables, funciones)

Este ordenamiento evita problemas donde los alias necesitan que sus tipos subyacentes sean determinados primero.

Ahora aquí hay un problema complicado: ¿qué pasa si tienes declaraciones que dependen entre sí? Por ejemplo:

type A B
type B A

Si el comprobador de tipos intenta determinar el tipo de A, necesita mirar B. Pero para determinar B, necesita mirar A. ¡Estamos atrapados en un bucle! Esto se llama un ciclo de dependencia, y el comprobador de tipos necesita detectar estos para evitar bucles infinitos.

¿La solución? Para cada objeto, el comprobador de tipos llama a objDecl (src/cmd/compile/internal/types2/decl.go:50), que implementa una técnica inteligente llamada el algoritmo de tres colores:

switch obj.color() {
case white:
    obj.setColor(grey + color(check.push(obj)))
    defer func() {
        check.pop().setColor(black)
    }()
case black:
    return  // Ya procesado
case grey:
    // ¡Ciclo detectado!
    if !check.validCycle(obj) {
        obj.typ = Typ[Invalid]
    }
}

Así es como funciona: los objetos comienzan blancos (sin procesar). Cuando el comprobador de tipos comienza a procesar un objeto, lo marca gris (en progreso). Cuando el procesamiento se completa, se vuelve negro (completo). Si el comprobador de tipos encuentra un objeto gris mientras procesa—significando que hemos vuelto a algo que ya estamos en medio de verificar—ese es un ciclo.

Sigamos a través de nuestro ejemplo type A B / type B A:

  1. Comprobador de tipos comienza con A (blanco) → lo marca gris, comienza a verificar
  2. Para verificar A, necesita verificar B
  3. Comprobador de tipos comienza con B (blanco) → lo marca gris, comienza a verificar
  4. Para verificar B, necesita verificar A
  5. Comprobador de tipos encuentra A otra vez—¡pero ya está gris!
  6. ¡Ciclo detectado! El comprobador de tipos reporta un error.

Si los tipos fueran válidos, como type List struct { next *List }, la recursión está bien porque hay una indirección de puntero. El comprobador de tipos permite estos ciclos válidos y solo rechaza los inválidos.

El algoritmo de tres colores se ejecuta para cada declaración que el comprobador de tipos procesa. De vuelta en nuestro programa hola mundo, cuando el comprobador de tipos llega a la declaración de función main, pasa por el mismo proceso de marcado de color—aunque afortunadamente, nuestro programa simple no tiene ciclos de qué preocuparse.

Esto es lo que sucede cuando el comprobador de tipos procesa main:

func (check *Checker) funcDecl(obj *Func, decl *declInfo) {
    // Verifica tipos de la firma de función
    sig := check.funcType(fdecl.Type, fdecl.TParamList)
    obj.typ = sig

    // Difiere verificación de cuerpo
    check.later(func() {
        check.funcBody(decl, name, sig, fdecl.Body, iota)
    })
}

El comprobador de tipos valida la firma de main: sin parámetros, sin valores de retorno. Almacena el tipo de la función—una Signature—pero difiere verificar el cuerpo hasta más tarde. Esta es la llamada check.later() agregando una acción diferida.

¿Por qué diferir? Porque los cuerpos de función pueden referenciar cualquier declaración a nivel de paquete, incluso aquellas declaradas más tarde en el archivo. Al diferir, el comprobador de tipos asegura que todas las declaraciones son conocidas antes de verificar cuerpos de función. Esto nos lleva a la siguiente fase.

Fase 4: Verificando Cuerpos de Función

¿Recuerdas todas esas acciones diferidas que pusimos en cola? Ahora es cuando se ejecutan. processDelayed (src/cmd/compile/internal/types2/check.go:540) procesa todas esas acciones diferidas—principalmente verificación de cuerpos de función.

El comprobador de tipos configura el entorno de la función (scope, firma, etc.) y luego verifica todas las sentencias en el cuerpo:

func (check *Checker) funcBody(decl *declInfo, name string, sig *Signature, body *syntax.BlockStmt, iota constant.Value) {
    // Configura entorno de función
    check.environment = environment{
        decl:  decl,
        scope: sig.scope,
        sig:   sig,
    }

    // Verifica todas las sentencias en el cuerpo
    check.stmtList(0, body.List)

    // Valida etiquetas y retornos
    // Verifica variables no usadas
}

Para nuestra función main, solo hay una sentencia: fmt.Println("Hello world"). Sigamos qué sucede con esta línea.

Resolviendo el selector

Lo primero que hay que averiguar es a qué se refiere realmente fmt.Println. El comprobador de tipos necesita resolver ambas partes de esta expresión selectora.

Comienza buscando fmt en el scope actual. Busca a través del scope local de la función, luego el scope del archivo, y lo encuentra—es el paquete que importamos en la parte superior del archivo. ¡Genial!

Ahora necesita buscar Println dentro de ese paquete. El comprobador de tipos busca en los símbolos exportados del paquete fmt y encuentra Println—es una función exportada. El comprobador de tipos registra esta resolución para uso posterior.

Verificando la llamada

Ahora que sabemos qué estamos llamando, el comprobador de tipos necesita validar la llamada misma:

func (check *Checker) call(x *operand, call *syntax.CallExpr) exprKind {
    // x es la función siendo llamada (fmt.Println)
    // call.ArgList contiene los argumentos

    // Obtiene firma de función
    sig := x.typ.(*Signature)

    // Verifica cada argumento contra parámetros
    check.arguments(call, sig, call.ArgList)
}

El comprobador de tipos toma la firma de Println del paquete fmt: func(a ...any) (n int, err error). Esta firma nos dice que Println acepta un parámetro variádico de tipo any y devuelve un int y un error.

Ahora viene la verificación de argumentos. Nuestra llamada pasa un argumento: el literal de cadena "Hello world". El comprobador de tipos necesita verificar que una cadena es compatible con el tipo de parámetro ...any. Ya que any acepta literalmente cualquier tipo, una cadena es definitivamente válida. ¡La verificación pasa!

Si hubiéramos pasado algo incompatible—digamos, intentamos llamar a una función esperando un int con un string—aquí es donde el comprobador de tipos lo detectaría y reportaría un error.

Registrando información de tipo

Finalmente, el comprobador de tipos registra que esta expresión de llamada devuelve (int, error):

check.recordTypeAndValue(call, value, sig.Results(), nil)

Aunque estamos ignorando esos valores de retorno en nuestro código, la información de tipo se rastrea para uso posterior.

El comprobador de tipos ahora ha validado completamente nuestro programa hola mundo! Todos los nombres están resueltos, todos los tipos verifican, y no hay errores. Las fases restantes (limpieza, initOrder, unusedImports, etc.) manejan tareas finales de contabilidad, pero el trabajo pesado está hecho.

Nuestro ejemplo hola mundo muestra la mecánica básica, pero el comprobador de tipos es capaz de manejar escenarios mucho más interesantes.

¿Qué Hay de Casos Más Complejos?

El comprobador de tipos lidia con algunas situaciones bastante sofisticadas. Echemos un vistazo a algunos ejemplos para ver qué tan profundo va este sistema.

Constantes Sin Tipo

Considera:

const x = 42
var y int = x
var z float64 = x

La constante 42 es sin tipo—tiene una clase (entero) pero no un tipo concreto todavía. El comprobador de tipos rastrea expresiones sin tipo y propaga tipos basándose en contexto:

func (check *Checker) updateExprType(x syntax.Expr, typ Type, final bool) {
    // Si x es sin tipo, actualiza su tipo basándose en contexto
    // Actualiza recursivamente expresiones sin tipo anidadas
}

Al asignar x a y, el comprobador de tipos convierte 42 a int. Al asignar a z, lo convierte a float64. Es por eso que las constantes sin tipo de Go “simplemente funcionan.” La capacidad del comprobador de tipos para rastrear y propagar expresiones sin tipo hace que el uso de constantes sea ergonomico.

Funciones Genéricas

Los genéricos agregan otra capa de complejidad. Para código genérico como:

func Min[T constraints.Ordered](a, b T) T {
    if a < b {
        return a
    }
    return b
}

result := Min(3, 5)  // Inferencia de tipo

El comprobador de tipos:

  1. Parsea los parámetros de tipo (T constraints.Ordered)
  2. Cuando ve Min(3, 5), infiere T = int de los argumentos
  3. Instancia Min[int] sustituyendo int por T
  4. Verifica que int satisface la restricción Ordered
  5. Valida el cuerpo de función con T reemplazado por int

Esto sucede en src/cmd/compile/internal/types2/infer.go usando un algoritmo de unificación. Es fascinante ver al comprobador de tipos deducir tipos automáticamente—parece magia, pero es solo contabilidad cuidadosa y resolución de restricciones. Me encantaría explorar cómo funcionan los genéricos bajo el capó con más detalle en un artículo futuro—hay mucho más por descubrir sobre inferencia de tipos, verificación de restricciones y generación de código.

Orden de Inicialización

Otro problema complicado que resuelve el comprobador de tipos: averiguar el orden para inicializar variables a nivel de paquete:

var a = b + 1
var b = 42

El comprobador de tipos calcula el orden de inicialización: b debe ser inicializada antes de a. Lo hace construyendo un grafo de dependencias y realizando un ordenamiento topológico (src/cmd/compile/internal/types2/initorder.go):

func (check *Checker) initOrder() {
    // Construye grafo de dependencias
    pq := dependencyGraph(check.objMap)

    // Ordenamiento topológico
    // Detecta ciclos
    // Emite orden de inicialización
}

Si hay un ciclo (var a = b; var b = a), el comprobador de tipos reporta un error.

Estos escenarios complejos muestran qué tan sofisticado es el comprobador de tipos. No es solo verificar “¿este int coincide con ese int?"—está resolviendo sistemas de restricciones, infiriendo tipos y calculando grafos de dependencias.

Pruébalo Tú Mismo

¿Quieres experimentar con verificación de tipos tú mismo? La biblioteca estándar de Go incluye un paquete go/types (similar a types2 pero usando go/ast en lugar de syntax). Aquí está cómo puedes verificar tipos de tu propio código Go:

package main

import (
    "fmt"
    "go/importer"
    "go/parser"
    "go/token"
    "go/types"
)

func main() {
    src := `package main

import "fmt"

func main() {
    fmt.Println("Hello world")
}`

    fset := token.NewFileSet()
    f, err := parser.ParseFile(fset, "hello.go", src, 0)
    if err != nil {
        panic(err)
    }

    conf := types.Config{Importer: importer.Default()}
    info := &types.Info{
        Defs:  make(map[*ast.Ident]types.Object),
        Uses:  make(map[*ast.Ident]types.Object),
        Types: make(map[ast.Expr]types.TypeAndValue),
    }

    pkg, err := conf.Check("main", fset, []*ast.File{f}, info)
    if err != nil {
        panic(err)
    }

    fmt.Printf("Package: %s\n", pkg.Path())
    fmt.Printf("Definitions: %d\n", len(info.Defs))
    fmt.Printf("Uses: %d\n", len(info.Uses))
}

Esto te muestra exactamente qué descubre el comprobador de tipos: qué identificadores son definiciones, cuáles son usos, y qué tipos se asignan a expresiones. ¡Intenta modificar el código fuente para introducir errores y ver qué detecta el comprobador de tipos!

Resumen

El comprobador de tipos es un paso crucial entre el parsing y la generación de IR en el compilador de Go. Toma el AST estructurado del parser y valida que tu código sigue las reglas de Go—resolviendo nombres, verificando tipos, y asegurando que todo tiene sentido.

Seguimos nuestro programa hola mundo y vimos cómo el comprobador de tipos trabaja en múltiples fases: primero recopila todas las declaraciones, luego verifica sus tipos (usando detección inteligente de ciclos para manejar tipos recursivos), y finalmente verifica cuerpos de función una vez que todas las declaraciones son conocidas. También exploramos cómo maneja escenarios más complejos como constantes sin tipo, genéricos y orden de inicialización.

El comprobador de tipos es minucioso y estricto, pero eso es lo que hace a Go confiable. Si tu código pasa la verificación de tipos, puedes estar confiado de que los tipos son correctos—sin sorpresas en tiempo de ejecución.

Si quieres profundizar más, recomiendo explorar src/cmd/compile/internal/types2/. El código está bien estructurado, y ahora que entiendes las fases y patrones, debería ser mucho más accesible.

En próximos artículos, cubriré el Unified IR y luego la Representación Intermedia completa—los siguientes pasos en transformar tu código hacia forma ejecutable.