AulaML Python · C++
C++ avanzado · 48 min · C++ para Inteligencia Artificial

Integración con Python y pybind11

Exponer C++ como módulo de Python, compartir arrays de NumPy y saber cuándo vale la pena

Python orquesta y C++ calcula: pybind11 permite que una función o una clase de C++ se importe como si fuera un módulo más. Verás cómo se construye la extensión, cómo se comparten arrays de NumPy sin copiar, cómo se maneja el GIL y en qué casos no merece la pena el cambio.

Lección 3 de 3 del módulo
Al terminar esta lección podrás
  • Escribir un módulo pybind11 con funciones, argumentos con nombre y docstrings
  • Compilar la extensión a mano y distribuirla con pyproject.toml o CMake
  • Pasar arrays de NumPy a C++ y distinguir vista de copia
  • Explicar el papel del GIL y liberarlo solo cuando es seguro
  • Decidir con datos si una parte del pipeline debe pasar a C++

Antes necesitas: Eigen: álgebra lineal en C++ · Machine Learning desde cero en C++ · RAII y punteros inteligentes

En esta lección
  1. ¿Qué es pybind11?
  2. ¿Para qué sirve?
  3. Cómo funciona por dentro: del .cpp al import
  4. Sintaxis: la forma canónica
  5. Ejemplo básico: un módulo sin dependencias
  6. Ejemplo intermedio: listas, optional y argumentos con nombre
  7. Caso práctico: NumPy, estado y GIL
  8. Visualización: qué cruza la frontera en una llamada
  9. Errores comunes
  10. Buenas prácticas
  11. Ejercicios
  12. Soluciones explicadas
  13. Resumen
  14. Evaluación

Tienes un pipeline en Python que funciona: carga datos, entrena, evalúa. El perfilado revela que el 78 % del tiempo se consume en un bucle que calcula distancias entre vectores, y ese bucle no se vectoriza bien porque actualiza estado en cada paso. Reescribir el proyecto entero en C++ sería caro y frágil; reescribir solo esa función y llamarla desde Python cuesta una tarde. Ese intercambio es exactamente el que resuelve pybind11.

La clave está en no mover los datos: un proceso, una memoria, dos lenguajes. Python decide qué hacer y C++ hace el trabajo pesado sobre la misma dirección de memoria. Lo que cambia es que tú respondes por la frontera: qué se copia, quién libera y cuándo se puede ejecutar código Python.

¿Qué es pybind11?

pybind11 es una biblioteca de cabecera (header-only) que genera el código de pegamento entre la API C de CPython y C++ moderno. No es un lenguaje intermedio ni un generador de archivos: describes el binding con plantillas de C++ y el compilador produce las funciones que Python necesita para crear el módulo.

La analogía útil es la de una aduana. Tu función mean vive en territorio C++; Python vive en el suyo. La macro PYBIND11_MODULE construye la oficina de aduanas: declara qué entra, con qué formulario se convierte cada objeto y qué se devuelve. Nada cruza gratis: cada argumento se convierte, se copia o se envuelve en un objeto Python que apunta al original.

¿Para qué sirve?

  • Acelerar bucles con estado: simulaciones de Monte Carlo, decodificación token a token, distancias con acumuladores. Aquí NumPy no ayuda porque cada paso depende del anterior.
  • Reutilizar una biblioteca C++ existente: Eigen, OpenCV, ONNX Runtime o tu propio motor matricial, sin escribir un servidor HTTP alrededor.
  • Exponer objetos con estado: un índice invertido, un modelo ya cargado en memoria, un buffer circular de métricas.
  • Compartir memoria con NumPy: pasar un ndarray y leerlo como double* sin copiar millones de elementos.
  • Distribuir el resultado: una wheel que se instala con pip install y funciona en el portátil de un compañero.

En Machine Learning la frontera aparece en tres puntos: el tokenizer de un modelo de lenguaje, los kernels de una capa personalizada y el preprocesado que no encaja en un ufunc. PyTorch y TensorFlow están construidos así: la API es Python, el cálculo es C++ y el puente es una extensión nativa.

Cómo funciona por dentro: del .cpp al import

El recorrido tiene tres etapas. Primero compilas un objeto compartido (.so en Linux y macOS, .pyd en Windows) enlazado contra las cabeceras de Python. Segundo, al escribir import faststats, CPython busca un archivo con la etiqueta ABI de tu intérprete (por ejemplo faststats.cpython-312-darwin.so), lo carga y llama a PyInit_faststats. Tercero, esa función —la genera PYBIND11_MODULE— construye el módulo y le cuelga las funciones y clases declaradas. Si el nombre del módulo y el del archivo no coinciden, el import no encuentra su punto de entrada.

Cada argumento que cruza se traduce con un caster. Estos son los casos que vas a usar el 90 % del tiempo:

PythonC++Qué ocurreCoste
intint / longConversión con comprobación de rangoBajo
floatdoubleConversión numéricaBajo
strstd::stringCopia del contenido UTF-8Medio
list / tuplestd::vector<T>Copia elemento a elemento (requiere stl.h)Alto en listas grandes
numpy.ndarraypy::array_t<T>Vista sobre el buffer si el dtype y el orden coinciden; copia con forcecastBajo o alto
Clase de C++py::class_<T>Objeto Python que envuelve un puntero al objeto C++Bajo

El GIL (Global Interpreter Lock) es el candado que impide que dos hilos ejecuten bytecode Python a la vez. Cuando Python llama a tu función, el hilo ya lo tiene. Si tu función solo toca memoria C++, puedes soltarlo con py::call_guard<py::gil_scoped_release>() para que otros hilos sigan trabajando. La regla es tajante: con el GIL liberado no puedes crear, leer ni modificar objetos py::, ni lanzar una excepción de Python; si lo haces, el proceso puede caerse sin mensaje.

Queda un detalle silencioso: la política de retorno. Al devolver un std::vector o un std::string, pybind11 copia a un objeto Python. Al devolver T& o un puntero decide según return_value_policy; si le pides reference, estarás prestando memoria C++ a Python, y si ese objeto muere antes que la referencia tendrás un fallo de segmentación lejos del origen.

Sintaxis: la forma canónica

Todo módulo tiene la misma silueta: una o más funciones o clases en C++, y una macro final que las publica. Los nombres que declares ahí son los que verá Python.

CPP esqueleto.cpp
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>   // habilita vector, map y optional

namespace py = pybind11;

int suma(int a, int b) { return a + b; }

PYBIND11_MODULE(mi_modulo, m) {        // debe coincidir con el nombre del archivo
    m.doc() = "Descripcion que muestra help(mi_modulo)";

    m.def("suma", &suma,                // nombre en Python, funcion de C++
          py::arg("a"), py::arg("b"),   // nombres de los argumentos
          "Devuelve a + b");            // docstring
}
  • m.def("nombre", &funcion, ...) registra una función libre. El primer argumento es el nombre visible en Python.
  • py::arg("x") permite llamar con palabra clave (f(x=3)) y aparece en la firma que muestra help().
  • py::class_<T>(m, "Nombre") publica una clase; .def(py::init<...>()) elige el constructor y .def("metodo", &T::metodo) los métodos.
  • py::call_guard<T>() ejecuta un objeto RAII alrededor de la llamada: el uso más común es liberar el GIL.
  • m.attr("VERSION") = "0.1.0" expone una constante, útil para comprobar qué binario está cargado.

Ejemplo básico: un módulo sin dependencias

Empieza por lo mínimo para aislar problemas de compilación. Este módulo no usa NumPy ni Eigen: solo dos funciones y un int que cruza la frontera.

CPP mates.cpp
#include <pybind11/pybind11.h>

namespace py = pybind11;

int suma(int a, int b) { return a + b; }
double media(double a, double b) { return (a + b) / 2.0; }

PYBIND11_MODULE(mates, m) {
    m.doc() = "Operaciones basicas escritas en C++";
    m.def("suma", &suma, py::arg("a"), py::arg("b"), "Suma dos enteros");
    m.def("media", &media, py::arg("a"), py::arg("b"), "Media de dos numeros");
}
SHELL compilar en Linux o macOS
# 1. Instala pybind11 en el mismo intérprete que usará el módulo
python3 -m pip install pybind11

# 2. Compila el objeto compartido con el sufijo ABI correcto
g++ -O2 -std=c++20 -Wall -Wextra -shared -fPIC \
    $(python3 -m pybind11 --includes) \
    mates.cpp -o mates$(python3 -c "import sysconfig; print(sysconfig.get_config_var('EXT_SUFFIX'))")

# 3. Comprueba que se importa y responde
python3 -c "import mates; print(mates.suma(2, 3), mates.media(2, 3))"

La salida es 5 2.5. Cada bandera tiene una razón: -shared -fPIC produce código independiente de la posición que puede cargarse en un proceso ya en marcha; --includes añade las rutas de Python y de pybind11; el sufijo EXT_SUFFIX evita que un módulo compilado para 3.11 se cargue por error en 3.12. -O2 importa: sin optimización el cálculo puede quedar más lento que el bucle de Python que intentabas sustituir.

Ejemplo intermedio: listas, optional y argumentos con nombre

El siguiente paso es aceptar colecciones. Con #include <pybind11/stl.h>, pybind11 convierte una list de Python en std::vector<double> y un std::optional<T> en T o None. Fíjate en el detalle importante: la lista se copia; si son diez millones de elementos, esa copia se come la ganancia.

CPP estadisticas.cpp
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>

#include <cstddef>
#include <optional>
#include <stdexcept>
#include <vector>

namespace py = pybind11;

double media(const std::vector<double>& valores) {
    if (valores.empty()) throw std::invalid_argument("media espera al menos un valor");
    double suma = 0.0;
    for (double v : valores) suma += v;
    return suma / static_cast<double>(valores.size());
}

std::optional<std::size_t> indice_max(const std::vector<double>& valores) {
    if (valores.empty()) return std::nullopt;
    std::size_t mejor = 0;
    for (std::size_t i = 1; i < valores.size(); ++i) {
        if (valores[i] > valores[mejor]) mejor = i;
    }
    return mejor;
}

PYBIND11_MODULE(estadisticas, m) {
    m.def("media", &media, py::arg("valores"), "Media aritmetica de una lista");
    m.def("indice_max", &indice_max, py::arg("valores"),
          "Indice del mayor valor, o None si la lista esta vacia");
}
PYTHON uso desde Python
import estadisticas

print(estadisticas.media([1, 2, 3, 4]))          # 2.5
print(estadisticas.indice_max([3.5, 9.0, 2.0]))  # 1
print(estadisticas.indice_max([]))               # None

print(estadisticas.media(valores=[10, 20]))      # 15.0
try:
    estadisticas.media([])
except ValueError as error:
    print("ValueError:", error)

Dos traducciones automáticas merecen atención. throw std::invalid_argument(...) llega a Python como ValueError con el mismo mensaje, así que no necesitas comprobar códigos de error: usa excepciones y deja que pybind11 las traduzca. Y std::nullopt se convierte en None, lo que permite expresar «no hay resultado» sin inventar un valor centinela como -1.

La misma media, a los dos lados de la frontera
Python
PYTHON media.py
def media(valores):
    if not valores:
        raise ValueError("media espera al menos un valor")
    return sum(valores) / len(valores)
C++
CPP media.cpp
double media(const std::vector<double>& valores) {
    if (valores.empty())
        throw std::invalid_argument("media espera al menos un valor");
    double suma = 0.0;
    for (double v : valores) suma += v;
    return suma / static_cast<double>(valores.size());
}

La lógica es idéntica; cambia quién paga el coste. La versión de Python crea un objeto por elemento al iterar; la de C++ recorre memoria contigua. Pero al llamarla desde Python, la lista se convierte a `std::vector` antes de entrar: con pocos elementos gana Python, con millones gana C++ solo si evitas la copia usando arrays.

Caso práctico: NumPy, estado y GIL

Este es el módulo que justifica la lección. py::array_t<double> con c_style | forcecast acepta cualquier array convertible a double en orden C, y request() devuelve un buffer_info con puntero, forma y strides: si el array ya es contiguo de double, C++ lee la misma memoria que NumPy. Además publicamos una clase con estado y una función que libera el GIL.

CPP faststats.cpp
#include <pybind11/numpy.h>
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>

#include <algorithm>
#include <cmath>
#include <cstddef>
#include <deque>
#include <stdexcept>
#include <vector>

namespace py = pybind11;

// Acepta cualquier array convertible a double contiguo (C-order).
double mean(py::array_t<double, py::array::c_style | py::array::forcecast> valores) {
    auto info = valores.request();
    if (info.ndim != 1) throw std::invalid_argument("mean espera un array 1-D");
    const auto n = static_cast<std::size_t>(info.shape[0]);
    if (n == 0) throw std::invalid_argument("mean espera al menos un valor");
    const auto* datos = static_cast<const double*>(info.ptr);
    double suma = 0.0;
    for (std::size_t i = 0; i < n; ++i) suma += datos[i];
    return suma / static_cast<double>(n);
}

std::vector<double> normalizar(const std::vector<double>& valores) {
    double maximo = 0.0;
    for (double v : valores) maximo = std::max(maximo, std::abs(v));
    if (maximo == 0.0) return std::vector<double>(valores.size(), 0.0);
    std::vector<double> salida;
    salida.reserve(valores.size());
    for (double v : valores) salida.push_back(v / maximo);
    return salida;
}

// Trabajo puro de C++: puede soltar el GIL durante el bucle.
long long sumar_hasta(long long n) {
    long long total = 0;
    for (long long i = 1; i <= n; ++i) total += i;
    return total;
}

class MediaMovil {
public:
    explicit MediaMovil(std::size_t ventana) : ventana_(ventana) {
        if (ventana == 0) throw std::invalid_argument("la ventana debe ser mayor que cero");
    }

    double push(double valor) {
        buffer_.push_back(valor);
        suma_ += valor;
        if (buffer_.size() > ventana_) {
            suma_ -= buffer_.front();
            buffer_.pop_front();
        }
        return suma_ / static_cast<double>(buffer_.size());
    }

    std::size_t size() const noexcept { return buffer_.size(); }

private:
    std::size_t ventana_;
    std::deque<double> buffer_;
    double suma_ = 0.0;
};

PYBIND11_MODULE(faststats, m) {
    m.doc() = "Estadisticas basicas implementadas en C++";

    m.def("mean", &mean, py::arg("valores"), "Media aritmetica de un array 1-D");
    m.def("normalizar", &normalizar, py::arg("valores"),
          "Divide cada valor por el maximo absoluto");
    m.def("sumar_hasta", &sumar_hasta, py::arg("n"),
          py::call_guard<py::gil_scoped_release>(),
          "Suma 1..n liberando el GIL mientras trabaja");

    py::class_<MediaMovil>(m, "MediaMovil")
        .def(py::init<std::size_t>(), py::arg("ventana"))
        .def("push", &MediaMovil::push, py::arg("valor"),
             "Anade un valor y devuelve la media de la ventana")
        .def_property_readonly("size", &MediaMovil::size)
        .def("__repr__", [](const MediaMovil& mm) {
            return "<MediaMovil size=" + std::to_string(mm.size()) + ">";
        });
}
SHELL compilación (Linux; en macOS añade -undefined dynamic_lookup)
g++ -O2 -std=c++20 -Wall -Wextra -shared -fPIC \
    $(python3 -m pybind11 --includes) \
    faststats.cpp -o faststats$(python3 -c "import sysconfig; print(sysconfig.get_config_var('EXT_SUFFIX'))")
PYTHON medir antes de celebrar
import time

import numpy as np

import faststats

rng = np.random.default_rng(0)
datos = rng.random(2_000_000)

inicio = time.perf_counter()
resultado_cpp = faststats.mean(datos)
t_cpp = time.perf_counter() - inicio

inicio = time.perf_counter()
resultado_py = sum(datos) / len(datos)
t_py = time.perf_counter() - inicio

print(f"C++  : {resultado_cpp:.6f} en {t_cpp * 1000:.2f} ms")
print(f"Python: {resultado_py:.6f} en {t_py * 1000:.2f} ms")
print(f"factor: {t_py / t_cpp:.1f}x")

mm = faststats.MediaMovil(2)
print(mm.push(1.0), mm.push(2.0), mm.push(3.0), mm.size, mm)
print(faststats.sumar_hasta(10))
SHELL salida esperada (las cifras dependen de la máquina)
C++  : 0.499925 en 1.90 ms
Python: 0.499925 en 190.00 ms
factor: 100.0x
1.0 1.5 2.5 2 <MediaMovil size=2>
55

Dos observaciones honestas. La primera: dos órdenes de magnitud es habitual en bucles puros sobre memoria, pero no es una ley; mide con time.perf_counter y varias repeticiones, porque el primer import incluye cargar el .so. La segunda: sum(datos) / len(datos) itera elementos de NumPy uno a uno; si comparas contra datos.mean() la ventaja de C++ desaparece, y ese es el caso en el que no debes escribir una extensión.

La clase MediaMovil muestra el otro motivo para usar pybind11: mantener estado que Python no debe reconstruir en cada llamada. El objeto C++ vive dentro del objeto Python; cuando Python lo recolecta, el destructor se ejecuta y el std::deque libera su memoria. No hay new, no hay delete, no hay fuga.

Visualización: qué cruza la frontera en una llamada

Sigue la ejecución paso a paso. Lo relevante no es el resultado —2.0 es trivial— sino cuándo se convierte cada objeto.

Ejecución visual ·Import, conversión de buffer y retorno
1 / 7
1 import numpy as np
2 import faststats
3
4 datos = np.array([1.0, 2.0, 3.0])
5 resultado = faststats.mean(datos)
6 print(resultado)
NumPy se carga como cualquier módulo. Todavía no hay nada de C++ propio en juego.
Variables
np = <module numpy>
faststats = —
datos = —
resultado = —
Salida
—

Avanza paso a paso: observa la línea resaltada, las variables y la salida.

Errores comunes

El nombre del módulo y el del archivo deben coincidir
Incorrecto
PYTHON faststats.cpp
PYBIND11_MODULE(_faststats, m) {
    m.def("mean", &mean);
}
Correcto
PYTHON faststats.cpp
PYBIND11_MODULE(faststats, m) {
    m.def("mean", &mean);
}

Python busca la función `PyInit_faststats` dentro de `faststats.cpython-312-....so`. Si la macro declara `_faststats`, el símbolo exportado es otro y el import falla con `ImportError: dynamic module does not define module export function (PyInit_faststats)`. El archivo y la macro son un contrato.

  • TypeError: ... incompatible function arguments ... Did you forget to #include <pybind11/stl.h>? — intentas pasar una lista a un std::vector sin la cabecera de conversiones. Añádela; el mensaje de pybind11 es literalmente ese.
  • ModuleNotFoundError: No module named faststats — el .so quedó en otro directorio o con otro sufijo. Ejecuta Python desde la carpeta del binario y comprueba el sufijo con sysconfig.get_config_var.
  • ImportError: ... undefined symbol — el módulo se compiló contra otra versión de Python. Recompila con el mismo intérprete que lo importa: python3 -m pybind11 --includes debe apuntar a ese intérprete.
  • Segmentation fault al llamar — casi siempre es una referencia a un objeto ya destruido o un return_value_policy::reference sobre memoria local.
  • Cuelgue al liberar el GIL — tocaste objetos py:: (imprimir, crear una lista, lanzar py::value_error) dentro de un gil_scoped_release. Copia los datos a estructuras C++ antes de soltar el candado.
  • Cambios que no se ven — Python cargó un .so viejo de otro directorio. Imprime faststats.__file__ antes de depurar la lógica.

Buenas prácticas

  • Mide antes y después con el mismo hardware y los mismos datos. Sin número previo no hay optimización, solo intuición.
  • Cruza la frontera pocas veces con muchos datos, no muchas veces con pocos: cada llamada tiene un coste fijo.
  • Prefiere py::array_t<T> sobre std::vector<T> cuando los datos vengan de NumPy; evitas una copia completa.
  • Escribe py::arg y docstrings en todas las funciones públicas: son la documentación que verá quien use el módulo.
  • Usa std::optional y excepciones estándar en lugar de centinelas; pybind11 los traduce a None y a excepciones de Python.
  • Libera el GIL solo en funciones que no toquen objetos Python y que duren lo suficiente para amortizar el cambio de contexto.
  • Automatiza la compilación con pyproject.toml o CMake. Una línea de g++ del historial no es reproducible.
SHELL pyproject.toml
[build-system]
requires = ["setuptools>=68", "pybind11>=2.12"]
build-backend = "setuptools.build_meta"

[project]
name = "faststats"
version = "0.1.0"
requires-python = ">=3.10"
PYTHON setup.py
from pybind11.setup_helpers import Pybind11Extension, build_ext
from setuptools import setup

ext_modules = [Pybind11Extension("faststats", ["faststats.cpp"], cxx_std=20)]
setup(ext_modules=ext_modules, cmdclass={"build_ext": build_ext})
SHELL instalación en modo editable y comprobación
python3 -m pip install -e .
python3 -c "import faststats; print(faststats.mean([1, 2, 3]))"   # 2.0
SHELL CMakeLists.txt para integrarlo en un proyecto existente
# CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(faststats LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(pybind11 CONFIG REQUIRED)
pybind11_add_module(faststats faststats.cpp)

# configuración y compilación
python3 -m pip install pybind11
cmake -S . -B build -Dpybind11_DIR=$(python3 -m pybind11 --cmakedir)
cmake --build build -j

Ejercicios

Ejercicio 1

Producto escalar con argumentos con nombre

Escribe un módulo vector_ops con una función producto_punto(a, b) que reciba dos listas de double, compruebe que tienen la misma longitud, lance std::invalid_argument si no coinciden y devuelva la suma de productos. Compílalo, llama con palabras clave desde Python y provoca el error para ver qué excepción llega.
Pista

Necesitas #include <pybind11/stl.h> para aceptar listas. Recorre con un índice std::size_t y acumula en un double.

Ejercicio 2

Una clase con propiedad de solo lectura

Publica una clase Contador con incrementar(), reiniciar() y una propiedad valor que no se pueda asignar desde Python. Añade un __repr__. Comprueba en el intérprete que contador.valor = 10 lanza AttributeError.
Pista

def_property_readonly produce una propiedad sin setter. Para el constructor vacío usa py::init<>().

Completar código Completa el binding
PYBIND11_MODULE(, m) {
    m.def("escalar", &escalar, , py::arg("factor"), "Multiplica el vector por factor");
}

El primer hueco es el nombre del módulo, que debe coincidir con el archivo. El segundo declara el nombre del primer argumento.

Soluciones explicadas

En el primer ejercicio, la decisión relevante no es el bucle sino el contrato: dos vectores de distinta longitud no tienen producto escalar, y fallar rápido con una excepción es mejor que devolver un número incorrecto en silencio. La versión con listas copia ambos vectores a std::vector al entrar: aceptable para cientos de elementos, inaceptable para millones, donde cambiarías la firma a py::array_t<double> para leer el buffer original.

En el segundo, def_property_readonly no impide que C++ modifique el valor: impide que Python lo asigne. Esa asimetría es intencionada. Y el __repr__ no es cosmético: cuando un objeto aparece dentro de una lista en un error, su representación es lo único que verás.

Resumen

  • Una extensión nativa es un objeto compartido que Python carga e inicializa mediante PyInit_<modulo>.
  • PYBIND11_MODULE(nombre, m) genera ese punto de entrada; nombre debe coincidir con el archivo.
  • m.def, py::arg y py::class_ publican funciones y clases; .def(py::init<...>()) elige el constructor.
  • Con stl.h se convierten listas en std::vector copiando; con py::array_t puedes leer el buffer de NumPy sin copia.
  • Las excepciones estándar de C++ llegan como excepciones de Python y std::optional como None.
  • El GIL protege el estado de CPython: se libera con py::call_guard<py::gil_scoped_release>() solo en código que no toque objetos py::.
  • La compilación se automatiza con pyproject.toml o CMake; una orden manual en la terminal no es reproducible.
  • Cruzar la frontera tiene coste fijo: acelera C++ cuando el trabajo por llamada es grande y no está ya vectorizado en NumPy.

Evaluación

Evaluación 1

Declaras PYBIND11_MODULE(_faststats, m) pero el archivo se llama faststats.cpp. ¿Qué ocurre al importar?

Evaluación 2

Quieres que una función C++ reciba una lista de Python como std::vector<double>. ¿Qué añades?

Evaluación 3

¿Cuándo es seguro añadir py::call_guard<py::gil_scoped_release>() a una función?

Tus notas

¿Ya lo entendiste?

Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.