Características de lenguajes orientados a objetos
No hay consenso en la comunidad de programación sobre qué características debe tener un lenguaje para ser considerado orientado a objetos. Rust está influenciado por muchos paradigmas de programación, incluido OOP; por ejemplo, exploramos las características que provienen de la programación funcional en el Capítulo 13. Es discutible que los lenguajes OOP compartan ciertas características comunes, a saber, objetos, encapsulación y herencia. Veamos qué significa cada una de esas características y si Rust la admite.
Los objetos contienen datos y comportamiento
El libro Design Patterns: Elements of Reusable Object-Oriented Software de Erich Gamma, Richard Helm, Ralph Johnson y John Vlissides (Addison-Wesley Professional, 1994), coloquialmente conocido como el libro Gang of Four, es un catálogo de patrones de diseño orientados a objetos. Define OOP de esta manera:
Los programas orientados a objetos están compuestos por objetos. Un objeto empaqueta tanto datos como los procedimientos que operan en esos datos. Los procedimientos se denominan típicamente métodos u operaciones.
Usando esta definición, Rust es orientado a objetos: los structs y los
enums tienen datos, y los bloques impl
proporcionan métodos en structs y
enums. Aunque los structs y los enums con métodos no se llaman objetos,
proporcionan la misma funcionalidad, según la definición de objetos del
Gang of Four’s.
Encapsulacion que oculta los detalles de implementacion
Otro aspecto comúnmente asociado con OOP es la idea de encapsulación, que significa que los detalles de implementación de un objeto no son accesibles al código que usa ese objeto. Por lo tanto, la única forma de interactuar con un objeto es a través de su API pública; el código que usa el objeto no debería poder acceder a los detalles internos del objeto y cambiar los datos o el comportamiento directamente. Esto permite al programador cambiar y refactorizar los detalles internos de un objeto sin necesidad de cambiar el código que usa el objeto.
Hemos discutido cómo controlar la encapsulación en el Capítulo 7: podemos usar
la palabra clave pub
para decidir qué módulos, tipos, funciones y métodos en
nuestro código deben ser públicos, y por defecto todo lo demás es privado. Por
ejemplo, podemos definir un struct AveragedCollection
que tiene un campo que
contiene un vector de valores i32
. El struct también puede tener un campo que
contiene el promedio de los valores en el vector, lo que significa que el
promedio no tiene que calcularse a pedido cada vez que alguien lo necesite. En
otras palabras, AveragedCollection
almacenará en caché el promedio calculado
para nosotros. El Listado 17-1 tiene la definición del struct
AveragedCollection
:
Filename: src/lib.rs
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
Listing 17-1: Un struct AveragedCollection
que
mantiene una lista de enteros y el promedio de los elementos en la colección
El struct está marcado como pub
para que otro código pueda usarlo, pero los
campos dentro del struct permanecen privados. Esto es importante en este caso
porque queremos asegurarnos de que cada vez que se agrega o elimina un valor de
la lista, el promedio también se actualiza. Hacemos esto implementando los
métodos públicos add
, remove
y average
en el struct, como se muestra en
el Listado 17-2:
Filename: src/lib.rs
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
impl AveragedCollection {
pub fn add(&mut self, value: i32) {
self.list.push(value);
self.update_average();
}
pub fn remove(&mut self) -> Option<i32> {
let result = self.list.pop();
match result {
Some(value) => {
self.update_average();
Some(value)
}
None => None,
}
}
pub fn average(&self) -> f64 {
self.average
}
fn update_average(&mut self) {
let total: i32 = self.list.iter().sum();
self.average = total as f64 / self.list.len() as f64;
}
}
Listing 17-2: Implementaciones de los métodos públicos
add
, remove
, y average
en AveragedCollection
Los métodos públicos add
, remove
, y average
son las únicas formas de
acceder o modificar los datos en una instancia de AveragedCollection
. Cuando
se agrega un elemento a list
usando el método add
o se elimina usando el
método remove
, las implementaciones de cada uno llaman al método privado
update_average
que maneja la actualización del campo average
también.
Dejamos los campos list
y average
privados para que no haya forma de que el
código externo agregue o elimine elementos de list
directamente; de lo
contrario, el campo average
podría quedar fuera de sincronización cuando
list
cambia. El método average
devuelve el valor en el campo average
,
permitiendo que el código externo lea el average
pero no lo modifique.
Debido a que hemos encapsulado la implementación de AveragedCollection
, podemos
cambiar fácilmente los aspectos, como la estructura de datos, en el futuro. Por
ejemplo, podríamos usar un HashSet<i32>
en lugar de un Vec<i32>
para el
campo list
. Mientras las firmas de los métodos públicos add
, remove
, y
average
permanezcan iguales, el código que usa AveragedCollection
no
necesitaría cambiar para compilar. Si hicimos list
pública en su lugar, esto
no sería necesariamente cierto: HashSet<i32>
y Vec<i32>
tienen diferentes
métodos para agregar y eliminar elementos, por lo que el código externo
probablemente tendría que cambiar si estuviera modificando list
directamente.
Si la encapsulación es un aspecto requerido para que un lenguaje se considere
orientado a objetos, entonces Rust cumple con ese requisito. La opción de usar
pub
o no para diferentes partes del código permite la encapsulación de los
detalles de implementación.
Herencia como un sistema de tipos y como Code Sharing
Herencia es un mecanismo mediante el cual un objeto puede heredar elementos de la definición de otro objeto, obteniendo así los datos y el comportamiento del objeto padre sin tener que definirlos nuevamente.
Si se considera que un lenguaje debe tener herencia para ser un lenguaje orientado a objetos, entonces Rust no cumple con esta definición. No existe una forma de definir un struct que herede los campos y las implementaciones de métodos de un struct padre sin usar una macro.
Sin embargo, si estás acostumbrado a tener la herencia en tu caja de programación, puedes usar otras soluciones en Rust, dependiendo de tu razón para recurrir a la herencia en primer lugar.
Elegirías la herencia por dos razones principales. Una es reutilizar el código:
puedes implementar un comportamiento particular para un tipo, y la herencia te
permite reutilizar esa implementación para un tipo diferente. Puedes hacer esto
de una manera limitada en el código Rust usando implementaciones de métodos
predeterminados de un trait, que viste en el Listado 10-14 cuando agregamos una
implementación predeterminada del método summarize
en el trait Summary
.
Cualquier tipo que implemente el trait Summary
tendría el método summarize
disponible sin ningún código adicional. Esto es similar a una clase padre que
tiene una implementación de un método y una clase hija heredada que también
tiene la implementación del método. También podemos anular la implementación
predeterminada del método summarize
cuando implementamos el trait Summary
,
lo que es similar a una clase hija anulando la implementación de un método
heredado de una clase padre.
La otra razón para usar la herencia está relacionada con el sistema de tipos: permitir que un tipo hijo se use en los mismos lugares que el tipo padre. Esto es también llamado polimorfismo, lo que significa que puedes sustituir múltiples objetos entre sí en tiempo de ejecución si comparten ciertas características.
Polimorfismo
Para muchas personas, el polimorfismo es sinónimo de herencia. Pero en realidad es un concepto más general que se refiere al código que puede trabajar con datos de múltiples tipos. Para la herencia, esos tipos son generalmente subclases.
En cambio, Rust utiliza generics para abstraerse sobre diferentes tipos posibles y los trait bounds para imponer restricciones sobre lo que esos tipos deben proporcionar. Esto se llama a veces polimorfismo paramétrico acotado.
En los últimos tiempos, la herencia ha perdido popularidad como solución de diseño de programas en muchos lenguajes de programación porque a menudo está en riesgo de compartir más código del necesario. Las subclases no siempre deben compartir todas las características de su clase padre, pero lo harán con la herencia. Esto puede hacer que el diseño de un programa sea menos flexible. También introduce la posibilidad de llamar a métodos en subclases que no tienen sentido o que causan errores porque los métodos no se aplican a la subclase. Además, algunos lenguajes solo permitirán una herencia única (lo que significa que una subclase solo puede heredar de una clase), lo que restringe aún más la flexibilidad del diseño de un programa.
Por estas razones, Rust toma un enfoque diferente utilizando trait objects en lugar de herencia. Veamos cómo los trait objects permiten el polimorfismo en Rust.