Parte 6 — Datos y estructuras · ⏱️ Duración estimada: 90 min · Nivel: Intermedio ✅ Clase construida — 10 implementaciones del núcleo verificadas contra
casos.json.
Trabajar con JSON: el formato universal de intercambio de datos. Todo lo visto en esta parte —arreglos, mapas, registros— vive dentro del proceso: son punteros, cabeceras y bloques de memoria que dejan de existir cuando el programa termina. Serializar es traducir esas estructuras a una secuencia de bytes que sobrevive al proceso y viaja entre máquinas; deserializar es reconstruirlas al otro lado. JSON se impuso como el idioma común de esa traducción por una razón que conviene entender: su modelo de datos —objetos, arreglos, cadenas, números, booleanos y nulo— es el mínimo común denominador de casi todos los lenguajes, de modo que un servidor en Go y un cliente en JavaScript pueden entenderse sin acordar nada más que el formato. Esa universalidad se paga con imprecisión: JSON no distingue enteros de reales, no tiene fechas, no tiene binarios y no describe su propio esquema. Kleppmann analiza este compromiso en Designing Data-Intensive Applications al comparar JSON con formatos con esquema como Avro o Protocol Buffers. En esta clase construyes a mano el objeto {"nombre": "Ada", "edad": 36} en diez lenguajes, precisamente para fijar el formato exacto antes de delegarlo en una biblioteca.
Al finalizar, podrás:
| # | Tema | Por qué importa |
|---|---|---|
| 1 | JSON | Formato de intercambio de datos |
| 2 | Serializar | De datos a texto JSON |
| 3 | Deserializar | De texto JSON a datos |
La gramática de JSON es minúscula y se puede enunciar entera en un párrafo, lo que explica buena parte de su éxito. Un valor JSON es una de seis cosas: un objeto ({} con pares "clave": valor separados por comas, y la clave siempre entre comillas dobles), un arreglo ([] con valores separados por comas), una cadena (comillas dobles obligatorias, con \", \\, \n y \uXXXX como escapes), un número (en notación decimal opcionalmente con exponente, sin comillas y sin distinción entre entero y real), un booleano (true o false en minúsculas) o null. No hay comentarios, no hay coma final permitida y no hay comillas simples. Esa rigidez es intencionada: cuanto más pequeña es la gramática, menos margen hay para que dos implementaciones la interpreten de forma distinta.
Serializar y deserializar son operaciones O(n) sobre el tamaño del texto, pero no son simétricas en riesgo. Serializar es un recorrido de la estructura escribiendo tokens; el único cuidado real es escapar correctamente las cadenas y detectar ciclos, porque un objeto que se contiene a sí mismo haría que el recorrido no termine. Deserializar es análisis sintáctico de entrada externa, y ahí toda la entrada es hostil: puede estar mal formada, puede tener campos que no esperas, puede faltar el que sí esperas, y puede ser lo bastante grande o profunda como para agotar la memoria o la pila. La primera regla práctica de la serialización es que serializar puedes hacerlo con confianza y deserializar nunca.
struct con etiquetas en Go, clases anotadas en Java). Formatos como Protocol Buffers o Avro lo exigen por adelantado y, a cambio, producen mensajes mucho más compactos y detectan las incompatibilidades antes.Las APIs web hablan JSON, y con eso se dice casi todo: un servidor en Go serializa una struct, la envía por HTTP, y un cliente en JavaScript la recibe como objeto sin que ninguno de los dos sepa nada del otro. Pero la situación interesante no es el camino feliz, sino lo que falla. Dos ejemplos que se ven a diario. El primero es el de los números grandes: JSON no distingue enteros de reales, y JSON.parse de JavaScript los convierte todos a double de 64 bits, cuya mantisa solo garantiza enteros exactos hasta 2^53; un identificador de Twitter o de Discord, que supera ese límite, llega al navegador con los últimos dígitos alterados. La solución de la industria fue enviar esos identificadores como cadenas. El segundo es el de las fechas: JSON no tiene tipo fecha, así que se transmiten como texto ISO 8601, y cada extremo debe acordar el formato y la zona horaria por su cuenta, porque el formato no lo impone. En ambos casos el problema no está en el código sino en el modelo de datos del formato, y por eso conviene conocerlo. El ejercicio de hoy construye a mano el objeto para que el formato quede grabado —comillas dobles en las claves y en la cadena, ninguna comilla en el número—, aunque en producción esa construcción manual sea exactamente lo que no debes hacer.
nombre edad (una palabra y un entero){"nombre": "<nombre>", "edad": <edad>}Especificación y verificación en casos.json:
| stdin | esperado |
|---|---|
Ada 36 |
{"nombre": "Ada", "edad": 36} |
Bo 5 |
{"nombre": "Bo", "edad": 5} |
Cy 99 |
{"nombre": "Cy", "edad": 99} |
LEER nombre, edad ; construir objeto ; serializar a JSON
Mismo algoritmo, forma idiomática en cada lenguaje. Todas producen la salida de casos.json.
Cada bloque es el archivo real de implementaciones/: el enlace de cada lenguaje abre su fuente, y el comando de al lado lo ejecuta.
python/main.py · python main.pyimport sys
t = sys.stdin.readline().split()
nombre, edad = t[0], int(t[1])
print(f'{{"nombre": "{nombre}", "edad": {edad}}}')
🧬 El mismo programa en la familia Scripting dinámico: Ruby · Perl · Lua · Tcl · R
javascript/main.mjs · node main.mjsimport { readFileSync } from "node:fs";
const t = readFileSync(0, "utf8").trim().split(/\s+/);
const nombre = t[0];
const edad = parseInt(t[1], 10);
console.log(`{"nombre": "${nombre}", "edad": ${edad}}`);
🧬 El mismo programa en la familia JavaScript / web: Dart · ActionScript
typescript/main.ts · pnpm exec tsx main.tsimport { readFileSync } from "node:fs";
const t: string[] = readFileSync(0, "utf8").trim().split(/\s+/);
const nombre: string = t[0];
const edad: number = parseInt(t[1], 10);
console.log(`{"nombre": "${nombre}", "edad": ${edad}}`);
🧬 El mismo programa en la familia JavaScript / web: Dart · ActionScript
java/Main.java · java Main.javaimport java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
public class Main {
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
String[] t = br.readLine().trim().split("\\s+");
String nombre = t[0];
int edad = Integer.parseInt(t[1]);
System.out.println("{\"nombre\": \"" + nombre + "\", \"edad\": " + edad + "}");
}
}
🧬 El mismo programa en la familia JVM: Kotlin · Scala · Groovy · Clojure
csharp/Program.cs · dotnet runusing System;
string[] t = Console.In.ReadToEnd()
.Split(new[] { ' ', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
string nombre = t[0];
int edad = int.Parse(t[1]);
Console.WriteLine($"{{\"nombre\": \"{nombre}\", \"edad\": {edad}}}");
🧬 El mismo programa en la familia .NET: F# · VB.NET
go/main.go · go run main.gopackage main
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
func main() {
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
t := strings.Fields(line)
nombre := t[0]
edad, _ := strconv.Atoi(t[1])
fmt.Printf("{\"nombre\": \"%s\", \"edad\": %d}\n", nombre, edad)
}
🧬 El mismo programa en la familia Sistemas: Zig · Nim · D
rust/main.rs · rustc main.rs -o main && ./mainuse std::io::Read;
fn main() {
let mut s = String::new();
std::io::stdin().read_to_string(&mut s).unwrap();
let t: Vec<&str> = s.split_whitespace().collect();
let nombre = t[0];
let edad: i64 = t[1].parse().unwrap();
println!("{{\"nombre\": \"{nombre}\", \"edad\": {edad}}}");
}
🧬 El mismo programa en la familia Sistemas: Zig · Nim · D
c/main.c · cc main.c -o main && ./main#include <stdio.h>
int main(void) {
char nombre[64];
long edad;
if (scanf("%63s %ld", nombre, &edad) != 2) return 1;
printf("{\"nombre\": \"%s\", \"edad\": %ld}\n", nombre, edad);
return 0;
}
🧬 El mismo programa en la familia C / llaves: C++ · Objective-C
sql/main.sql · sqlite3 :memory: < main.sql-- SQL: construye JSON con printf (o json_object en motores con la extensión).
WITH personas(nombre, edad) AS (VALUES ('Ada', 36))
SELECT printf('{"nombre": "%s", "edad": %d}', nombre, edad) AS resultado FROM personas;
🧬 El mismo programa en la familia Lógica y declarativa: Prolog · Datalog
php/main.php · php main.php<?php
$t = preg_split('/\s+/', trim(fgets(STDIN)));
$nombre = $t[0];
$edad = (int) $t[1];
echo "{\"nombre\": \"$nombre\", \"edad\": $edad}\n";
🧬 El mismo programa en la familia Scripting dinámico: Ruby · Perl · Lua · Tcl · R
SQL es declarativo: no lee de stdin como los demás; su implementación muestra la misma idea sobre una tabla de casos, y el verificador la marca como ilustrativa.
Sigamos el caso Ada 36, que debe producir exactamente {"nombre": "Ada", "edad": 36}. Fíjate en los cuatro detalles que el verificador comprueba carácter a carácter: llaves, comillas dobles alrededor de las claves y del valor de texto, ausencia de comillas alrededor del número, y un espacio después de cada dos puntos y de la coma.
En Python, la línea print(f'{{"nombre": "{nombre}", "edad": {edad}}}') esconde una peculiaridad de las f-strings: {{ y }} son la forma de escribir una llave literal, porque la llave simple abre una interpolación. Por eso el código usa comillas simples para delimitar la cadena de Python y reserva las dobles para el JSON: así no hace falta escapar nada más. nombre entra entre comillas porque es texto; edad, que se convirtió con int(t[1]), entra desnudo porque es número. En un programa real esta línea sería json.dumps({"nombre": nombre, "edad": edad}), que además escaparía correctamente un nombre que contuviera comillas o acentos.
En Java y C#, lo que domina la línea es el escapado de las comillas dobles, que en ambos lenguajes se escribe \". La concatenación de Java, "{\"nombre\": \"" + nombre + "\", \"edad\": " + edad + "}", es tan ilegible que sirve de argumento por sí sola a favor de las bibliotecas: cualquier lector tiene que contar comillas para verificarla. C# lo sufre igual dentro de su cadena interpolada y añade el problema de Python: como { abre la interpolación, hay que duplicarla a {{ para obtener una llave literal. Java 15 introdujo los bloques de texto (""") precisamente para que este tipo de literal deje de necesitar escapes.
En Rust, println!("{{\"nombre\": \"{nombre}\", \"edad\": {edad}}}") acumula las dos convenciones a la vez: {{ y }} para las llaves literales del macro de formato, y \" para las comillas de la cadena. Además exhibe la captura de identificadores por nombre —{nombre} toma directamente la variable del ámbito, sin pasarla como argumento—, disponible desde Rust 2021. Nota que edad se declaró como i64 y se imprime sin comillas: el tipo del lenguaje decide la forma del JSON.
En SQL, printf('{"nombre": "%s", "edad": %d}', nombre, edad) deja ver el mecanismo desnudo: %s es texto y va entre comillas escritas a mano, %d es entero y va sin ellas. Es exactamente lo que hacen los demás, solo que sin ocultarlo. En producción usarías json_object('nombre', nombre, 'edad', edad), la función que SQLite y PostgreSQL ofrecen para construir JSON con el escapado ya resuelto —y que además nunca produciría un JSON inválido a partir de un nombre con comillas—.
Los diez imprimen {"nombre": "Ada", "edad": 36}; el verificador comprueba que la salida coincide carácter a carácter con lo que dicta casos.json.
| Clase de diferencia | Observación entre lenguajes |
|---|---|
| Sintáctica | Librerías json (Python), JSON.stringify (JS), pero el formato es idéntico. |
| Semántica | Las cadenas van entre comillas dobles; los números sin comillas. |
| Paradigmática | SQL genera JSON con funciones json_object (aquí, con printf). |
La primera diferencia no está en el formato —que es literalmente el mismo en todas partes; ese es el sentido de JSON— sino en cómo cada lenguaje salva la distancia entre su sistema de tipos y el de JSON. Los lenguajes dinámicos casi no tienen distancia que salvar: JavaScript trae JSON.stringify y JSON.parse en el propio lenguaje, y Python mapea dict a objeto, list a arreglo y None a null de forma directa con el módulo json. Los lenguajes estáticos necesitan un puente explícito. Go usa etiquetas de struct —json:"nombre"— que la biblioteca encoding/json lee por reflexión para saber cómo se llama cada campo en el texto. Rust usa serde con #[derive(Serialize, Deserialize)], que genera el código de conversión en tiempo de compilación: sin reflexión y sin coste en ejecución. Java y C# recurren a bibliotecas con anotaciones (Jackson, System.Text.Json) que resuelven el mapeo por reflexión o, en el caso moderno de .NET, mediante generadores de código. En C no hay nada de esto en la biblioteca estándar y hay que enlazar una biblioteca externa o escribir el analizador a mano.
La segunda diferencia, y la que produce bugs reales de interoperabilidad, es cómo trata cada lenguaje los números. JSON solo tiene «número», sin especificar precisión. JavaScript lo interpreta siempre como double, y por eso pierde precisión más allá de 2^53. Python distingue int de float al parsear y admite enteros de precisión arbitraria, así que lee sin problema un número que JavaScript ya habría estropeado. Go, al deserializar en un interface{}, convierte todo número a float64 salvo que se use json.Number. Java con Jackson decide el tipo según el campo destino. Resultado: el mismo documento JSON puede producir valores distintos en dos lenguajes, y ese es el motivo por el que los identificadores grandes se transmiten como cadenas.
El tercer eje es el tratamiento de la ausencia. Go escribe el valor cero (0, "") para los campos no establecidos salvo que se marquen omitempty, de modo que no distingue «cero» de «no había dato». Rust modela la ausencia con Option<T>, que serializa a null y que obliga al programador a decidir qué hacer cuando falta. Java tiene null para todo y Optional, que Jackson necesita un módulo aparte para manejar. TypeScript declara la opcionalidad en el tipo (edad?: number), pero esa comprobación desaparece en ejecución: Cherny insiste en Programming TypeScript en que el JSON entrante debe validarse en tiempo de ejecución, porque el compilador no puede garantizar la forma de un dato que llega de la red.
Prácticamente todo lenguaje vivo trae JSON de serie o mediante una biblioteca canónica: to_json en Ruby, json_encode en PHP, Data.Aeson en Haskell, Jason en Elixir, kotlinx.serialization en Kotlin, Codable en Swift. El formato no cambia; lo que cambia es dónde se decide el mapeo. Hay tres escuelas. La dinámica convierte a estructuras genéricas —diccionarios y listas— y deja la validación al programador: rápida de escribir, frágil ante datos inesperados. La reflexiva —Jackson, encoding/json, System.Text.Json— inspecciona el tipo destino en tiempo de ejecución para rellenarlo; cómoda, con algo de coste y sorpresas cuando el tipo no encaja. La generativa —serde en Rust, Codable en Swift, kotlinx.serialization— produce el código de conversión al compilar: sin reflexión, sin coste añadido y con los errores de forma detectados antes de ejecutar. Conviene, por último, situar JSON entre sus parientes. XML es su antecesor: más verboso, pero con esquemas y espacios de nombres maduros. YAML (clase 106) es un superconjunto pensado para que lo escriban humanos. Protocol Buffers, Avro y MessagePack son binarios y con esquema: mucho más compactos y rápidos, a costa de no ser legibles y de exigir que ambos extremos compartan la definición. La elección, como resume Kleppmann, se reduce a un compromiso entre legibilidad y flexibilidad por un lado, y tamaño, velocidad y garantías de evolución del esquema por otro.
Los mismos casos para todas las implementaciones: casos.json. Verifica la equivalencia:
python scripts/verificar_equivalencia.py 105
Detalle en reto.md.
{'a': 1} es JSON inválido aunque sea un dict de Python perfectamente válido → solución: usar siempre comillas dobles, y no confundir la representación de un diccionario impresa por el lenguaje con JSON de verdad."edad": "36" convierte el número en cadena y el otro extremo recibirá texto donde esperaba un entero → solución: los números, booleanos y null van sin comillas; solo las cadenas las llevan.json.dumps, JSON.stringify, json_object), que escapa correctamente; el código de esta clase construye el texto a mano solo con fines didácticos y con datos controlados.double y pierde exactitud pasado 2^53, así que un identificador de 19 dígitos llega alterado → solución: transmitir los identificadores grandes como cadenas, o usar BigInt con un analizador que lo soporte.JSON.parse optimista se lo traga todo → solución: validar contra un esquema o unos tipos comprobados en ejecución (JSON Schema, Zod, serde con tipos concretos), y limitar el tamaño y la profundidad.package.json, tsconfig.json, composer.json), de registros estructurados —una línea JSON por evento, el patrón habitual de los sistemas de logs modernos— y de almacenamiento en bases documentales como MongoDB o en columnas jsonb de PostgreSQL, que además permiten indexarlo y consultarlo.tsconfig.json admiten comentarios pese a no ser JSON estricto. Los analizadores estándar los rechazan.JSON.stringify lanza una excepción al detectarla—. Lo que se serializa son datos, no objetos con identidad ni conducta.Libros de la parte:
Libros de los lenguajes del núcleo:
⏮️ Clase 104 · 📂 Parte · 📚 Índice · 🌐 Atlas · Clase 106 ⏭️