Esta es parte de una serie donde te guiaré a través del compilador completo de Go, cubriendo cada fase desde código fuente hasta ejecutable. Si alguna vez te preguntaste qué sucede cuando ejecutas go build, estás en el lugar correcto.
Nota: Este artículo está basado en Go 1.25.3. El funcionamiento interno del compilador pueden cambiar en versiones futuras, pero los conceptos centrales probablemente permanecerán iguales.
Voy a usar el ejemplo más simple posible para guiarnos a través del proceso—un programa clásico “hola mundo”:
package main
import "fmt"
func main() {
fmt.Println("Hello world")
}
Comencemos con el primer paso: el scanner.
Qué Hace el Scanner
El scanner de Go (también llamado lexer) es el primer componente del compilador. Su trabajo es directo: convertir tu código fuente en tokens. Cada token típicamente representa una palabra o símbolo—cosas como package, main, {, (, o cadenas de texto.
Un punto clave para entender el scanner es que lee tu código caracter a caracter y no le importa el contexto. No sabe si estás dentro de una función o declarando una variable. Solo sabe: “Esta secuencia de caracteres forma un token válido” o “Esto es inválido.”
El scanner también maneja inserción automática de punto y coma. Podrías no escribir puntos y coma en tu código Go, pero el scanner los agrega después de ciertos tokens cuando ve una nueva línea. Desde tu perspectiva, los puntos y coma son opcionales. Desde la perspectiva del compilador, siempre están ahí.
Dos Implementaciones de Scanner
Go en realidad tiene dos implementaciones de scanner:
- Scanner de biblioteca estándar (
src/go/scanner/) - Esto es lo que usarías si estás escribiendo herramientas Go que necesitan parsear código Go. - Scanner del compilador (
src/cmd/compile/internal/syntax/scanner.go) - Este es el real, lo que el compilador realmente usa.
Nos vamos a enfocar en el scanner del compilador.
Tokens: La Salida del Scanner
Veamos cómo se ven realmente los tokens. Cuando el scanner procesa nuestro programa hola mundo, produce esta secuencia:
Position Token Literal
-------- ----- -------
1:1 package "package"
1:9 IDENT "main"
1:14 ; "\n"
3:1 import "import"
3:8 STRING "\"fmt\""
3:13 ; "\n"
5:1 func "func"
5:6 IDENT "main"
5:10 ( ""
5:11 ) ""
5:13 { ""
6:5 IDENT "fmt"
6:8 . ""
6:9 IDENT "Println"
6:16 ( ""
6:17 STRING "\"Hello world\""
6:30 ) ""
6:31 ; "\n"
7:1 } ""
7:2 ; "\n"
Nota cómo el scanner insertó automáticamente puntos y coma (representados como nuevas líneas) después de main, después de la importación "fmt", y al final de la declaración print. Esta es esa inserción automática de punto y coma que mencioné.
Pruébalo Tú Mismo
La biblioteca estándar de Go incluye un paquete scanner que te permite experimentar con tokenización. Aquí hay un programa completo que puedes ejecutar:
package main
import (
"fmt"
"go/scanner"
"go/token"
)
func main() {
src := []byte(`package main
import "fmt"
func main() {
fmt.Println("Hello world")
}`)
var s scanner.Scanner
fset := token.NewFileSet()
file := fset.AddFile("", fset.Base(), len(src))
s.Init(file, src, nil, scanner.ScanComments)
for {
pos, tok, lit := s.Scan()
if tok == token.EOF {
break
}
fmt.Printf("%s\t%s\t%q\n", fset.Position(pos), tok, lit)
}
}
Ahora que entendemos qué produce el scanner, veamos cómo realmente funciona.
Dentro del Scanner
El scanner necesita ser inicializado antes de que pueda comenzar a escanear. Esto sucede en la función init (src/cmd/compile/internal/syntax/scanner.go):
func (s *scanner) init(src io.Reader, errh func(line, col uint, msg string), mode uint) {
s.source.init(src, errh)
s.mode = mode
s.nlsemi = false
}
Esto configura tres cosas clave: el lector del código fuente (de dónde viene el código), el modo de escaneo (si reportar comentarios, por ejemplo), y el estado de inserción de punto y coma (inicialmente apagado).
Bajo el capó, la inicialización del lector de código fuente hace el trabajo pesado:
func (s *source) init(in io.Reader, errh func(line, col uint, msg string)) {
s.in = in
s.errh = errh
if s.buf == nil {
s.buf = make([]byte, nextSize(0))
}
s.buf[0] = sentinel
s.ioerr = nil
s.b, s.r, s.e = -1, 0, 0
s.line, s.col = 0, 0
s.ch = ' '
s.chw = 0
}
Esto crea un lector con buffer que está optimizado para código Go. El buffer (buf) almacena trozos de código fuente, y tres índices (b, r, e) rastrean qué partes han sido leídas y qué partes se están procesando activamente. Los campos line y col rastrean la posición actual para reporte de errores. El sentinel es un marcador especial que hace más rápido detectar cuándo hemos alcanzado el final del contenido del buffer. Finalmente, ch contiene el carácter actual siendo examinado (inicializado a un espacio), y estamos listos para comenzar a leer.
Una vez inicializado, el scanner está listo para comenzar a producir tokens. Cada llamada a la función next avanza a través del código fuente hasta que encuentra el siguiente token.
Cómo Funciona el Reconocimiento de Tokens
Aquí es donde sucede la magia. Recorramos la función next en trozos.
Primero, el scanner maneja inserción de punto y coma:
func (s *scanner) next() {
nlsemi := s.nlsemi
s.nlsemi = false
El atributo nlsemi rastrea si el scanner debería insertar un punto y coma si encuentra una nueva línea. Así es como Go te permite omitir escribir puntos y coma—el scanner los agrega por ti después de ciertos tokens.
Luego, omite espacios en blanco:
redo:
// skip white space
s.stop()
startLine, startCol := s.pos()
for s.ch == ' ' || s.ch == '\t' || s.ch == '\n' && !nlsemi || s.ch == '\r' {
s.nextch()
}
La llamada stop asegura que estamos comenzando fresco con un nuevo token. Luego el scanner consume todos los caracteres de espacio en blanco hasta que se encuentra con algo significativo.
Después de eso, registra metadatos de token—específicamente, dónde comienza este token en el archivo fuente:
// token start
s.line, s.col = s.pos()
s.blank = s.line > startLine || startCol == colbase
s.start()
Esto captura la línea y columna donde comienza el token (para mensajes de error), verifica si la línea estaba en blanco hasta este punto (útil para herramientas de formateo), y marca el inicio del texto del token en el buffer.
Ahora el scanner necesita averiguar qué clase de token es este. Lo hace verificando el primer carácter. Comencemos con identificadores y keywords:
if isLetter(s.ch) || s.ch >= utf8.RuneSelf && s.atIdentChar(true) {
s.nextch()
s.ident()
return
}
Si el carácter actual es una letra (o un carácter identificador Unicode válido), el scanner sabe que está mirando ya sea una keyword (como package o func) o un identificador (como main o fmt). Consume ese primer carácter con nextch(), luego delega al método ident para leer el resto de los caracteres y determinar si es una keyword o identificador:
func (s *scanner) ident() {
// accelerate common case (7bit ASCII)
for isLetter(s.ch) || isDecimal(s.ch) {
s.nextch()
}
// general case
if s.ch >= utf8.RuneSelf {
for s.atIdentChar(false) {
s.nextch()
}
}
// possibly a keyword
lit := s.segment()
if len(lit) >= 2 {
if tok := keywordMap[hash(lit)]; tok != 0 && tokStrFast(tok) == string(lit) {
s.nlsemi = contains(1<<_Break|1<<_Continue|1<<_Fallthrough|1<<_Return, tok)
s.tok = tok
return
}
}
s.nlsemi = true
s.lit = string(lit)
s.tok = _Name
}
Aquí está lo que hace ident() paso a paso:
- Lee el identificador: Sigue consumiendo caracteres mientras sean letras o dígitos (manejando tanto ASCII como Unicode)
- Verifica si es una keyword: Una vez que tiene la palabra completa, la busca en el
keywordMapde Go usando una función hash para velocidad - Devuelve el token apropiado: Si encuentra una coincidencia en el mapa de keywords, devuelve ese token de keyword específico (como
_Packageo_Func). Si no hay coincidencia, es solo un identificador normal, así que devuelve_Namey almacena el texto real ens.lit
El flag nlsemi también se establece aquí—le dice al scanner si un punto y coma debería ser insertado automáticamente después de este token si sigue una nueva línea.
Manejando Símbolos y Operadores
Recuerda, si el primer carácter no era una letra, el camino de ident() no se ejecutó. En su lugar, el scanner continúa en la función next con una gran declaración switch que verifica qué clase de carácter estamos mirando. Aquí es donde se reconocen símbolos, operadores, números, cadenas y otros tokens. Veamos algunos ejemplos.
Fin de archivo es simple:
switch s.ch {
case -1:
if nlsemi {
s.lit = "EOF"
s.tok = _Semi
break
}
s.tok = _EOF
Cuando el scanner se encuentra con un -1 (EOF), devuelve el token apropiado. Si necesita insertar un punto y coma antes de EOF, lo hace primero.
Símbolos simples son directos:
case ',':
s.nextch()
s.tok = _Comma
case ';':
s.nextch()
s.lit = "semicolon"
s.tok = _Semi
Una coma es una coma. Un punto y coma es un punto y coma. Fácil.
Operadores multi-carácter:
case '+':
s.nextch()
s.op, s.prec = Add, precAdd
if s.ch != '+' {
goto assignop
}
s.nextch()
s.nlsemi = true
s.tok = _IncOp
Aquí es donde entra en juego el lookahead. Cuando el scanner ve un +, no puede decidir inmediatamente qué token es—podría ser +, ++, o +=. Así que consume el + con nextch() y luego verifica qué está en s.ch (el siguiente carácter en el flujo) sin consumirlo todavía. Esto es lookahead: echar un vistazo el siguiente carácter para tomar una decisión.
Si s.ch es otro +, tenemos el operador de incremento ++, así que consumimos ese segundo + y establecemos el token. Si no lo es, saltamos a la etiqueta assignop para verificar si es += o solo un + simple:
assignop:
if s.ch == '=' {
s.nextch()
s.tok = _AssignOp
return
}
s.tok = _Operator
Si el siguiente carácter es =, tenemos un operador de asignación como +=. Si no, es solo el operador por sí mismo, y el scanner no consume el siguiente carácter—lo deja para el siguiente token.
Casos Más Complejos
No he cubierto todo aquí. El scanner también maneja tokens de cadena (con secuencias de escape), tokens numéricos (incluyendo flotantes, exponentes y diferentes bases de números como hexadecimal y binario), y comentarios. Estos siguen patrones similares pero con más complejidad. Si tienes curiosidad, te animo a explorar src/cmd/compile/internal/syntax/scanner.go tú mismo.
Recorriendo un Ejemplo
Hemos cubierto mucho terreno—inicialización, reconocimiento de tokens, lookahead, y los diferentes caminos de código que toma el scanner. Ahora juntemos todo recorriendo nuestro programa hola mundo línea por línea. Esto te ayudará a ver cómo todas estas piezas trabajan juntas en la práctica, desde el primer carácter hasta el token EOF final.
El scanner comienza leyendo p, que es una letra. Continúa leyendo hasta que tiene la palabra completa package. Luego verifica si package es una keyword. Lo es, así que el scanner devuelve un token package.
Luego, lee m, otra letra. Sigue leyendo: a, i, n. Ahora tiene main. ¿Es esto una keyword? No. Así que el scanner devuelve un token IDENT con el literal "main".
Luego se encuentra un salto de linea. El token anterior era un identificador, lo que significa que el scanner debería insertar un punto y coma aquí. Lo hace.
Lo siguiente es import, que es una keyword. El scanner devuelve el token import.
El scanner luego encuentra ", señalando el inicio de una cadena. Lee la cadena completa "fmt" y devuelve un token STRING.
Después de otra nueva línea (e inserción de punto y coma), el scanner ve func, otra keyword.
Luego main otra vez—un identificador.
Los caracteres (, ) y { son todos tokens de un solo carácter, así que el scanner emite cada uno inmediatamente.
Lo siguiente es fmt (identificador), seguido de . (token de punto), seguido de Println (identificador).
Luego ( (paréntesis de apertura), la cadena "Hello world" (token de cadena), ) (paréntesis de cierre), y } (llave de cierre).
Finalmente, el scanner inserta un punto y coma después de la llave de cierre, se encuentra con el final del archivo, y devuelve un token EOF.
Y esa es la tokenización completa de un programa hola mundo.
Resumen
El scanner es la primera fase del compilador de Go. Lee tu código fuente carácter por carácter y produce un flujo de tokens—una representación mucho más estructurada con la que el resto del compilador puede trabajar.
Hemos visto cómo el scanner:
- Inserta automáticamente puntos y coma para que no tengas que hacerlo
- Distingue entre keywords e identificadores usando una tabla de búsqueda
- Maneja operadores multi-carácter usando lookahead
- Procesa diferentes tipos de tokens usando una combinación de lookahead y coincidencia de patrones
Si quieres profundizar más, recomiendo mucho leer el código real del scanner. Hay muchos detalles interesantes en cómo maneja cadenas, números y casos que no cubrí aquí.
¿Quieres Aprender Más?
Si te gustaría continuar explorando el compilador de Go:
- Echale un ojo a mi charla: Understanding the Go Compiler - Una inmersión profunda en cómo funciona el compilador de Go desde fuente hasta binario
- Prueba el taller: Having Fun with the Go Source Code - Ejercicios prácticos donde puedes experimentar con el compilador de Go tú mismo
En el siguiente artículo, hablaré sobre el parser—el componente que toma este flujo de tokens y construye un “Abstract Syntax Tree”, dando al compilador un entendimiento semántico de tu código.
