02.07.2022

Heterogeeninen lista

Rust-ohjelmointikielessä ei ole olio-ohjelmoinnin perintää. Kuitenkin hyödyllisimmät perinnän edut saadaan aikaan traiteilla. Tärkein käyttötarkoitus on heterogeeninen lista. Heterogeenisessa listassa on useita erityyppisiä olioita, joilla kaikilla on sama trait eli joukko samalla tavalla kutsuttavia funktioita.

Drawable ja executable -esimerkki

Peleissä käytetään paljon heterogeenisia listoja. Esimerkiksi virtuaalimaailman objektit käydään nopeasti läpi kutsumalla piirrettävissä olevien objektien draw()-funktiota ja esim. kerran mikrosekunnissa muutosta vaativien objektien tick()-funktiota. Itse objektit voivat muuten olla täysin erilaisia.

Tämä on perinteinen esimerkki heterogeenisista listoista, joita nyt tässäkin käytämme.

Olio-ohjelmoinnin ratkaisu

Olio-ohjelmoinnissa piirrettävissä olevat oliot ovat erikoistapauksia ("perivät") samasta drawable-luokasta. Silloin näitä olioita voi laittaa samaan taulukkoon, joka nopeasti käydään läpi kutsuen kaikille yhteistä draw()-funtiota.

Puuttumatta yksityiskohtiin, oliokielten perinnässä on aina jonkin verran ajonaikaista tehottomuutta. Myös jos sama objekti on sekä "drawable" että "executable", niiden on perittävä kummastakin luokasta. Tästä tulee omat sotkunsa, jotka eri oliokielet ratkaisevat eri tavalla.

Rust-trait

Rust ratkaisee ongelman traiteilla. Jos olioon on liitetty trait "Drawable", sen on toteutettava funktio Draw() identtisellä tavalla. Tämä ei kuitenkaan anna vielä lupaa kutsua tätä funktiota tietämättä olion tyyppiä käännösaikana.

Rust on staattinen kieli, jossa ei lisätä dynaamisia tietorakenteita turhan takia.

Esimerkiksi tyyppi "Line" voi olla

struct Line {
    x1: i32,
    x2: i32,
    y1: i32,
    y2: i32,
}

trait Drawable {
    fn draw(&self);
}

impl Drawable for Line {
    fn draw(&self) {
        println!("line");
    }
}

Tämä tarkoittaa, että jos tästä tyypistä luodaan muutuja, se on täsmälleen 4*32 bittiä eli 16 tavua. muuttujassa ei ole tietoa tyypistä tai ohjeita Draw()-funktion kutsumiseen.

Voit luoda Line-tyyppisen muuttujan ja kutsua sen Draw()-funktiota, esim.

let line = Line { x1: 1, x2: 2, y1: 3, y2: 4 };
line.Draw();

mutta tämä perustuu siihen, että kääntäjä tietää käännösaikana muuttujan line tyypin ja osaa siten kutsua oikeaa Draw()-funktiota.

Dynaaminen trait-objekti

Että voimme käsitellä samassa listassa eri tyyppisiä instansseja, joudumme luomaan trait-objekti -tyyppisen viittauksen varsinaiseen objektiin.

let line = Line { x1: 1, x2: 2, y1: 3, y2: 4 };
let l: &dyn Drawable = &line;
l.Draw();

Oikeaan muistissa olevaan objektiin line voi luoda viittauksen trait-objektina. Tähän viittaukseen lisätään tyyppi-informaatio niin, että viittausta voi käyttää myös sellaisissa tilanteissa, että tyyppi ei ole selvillä käännösaikana.

Esimerkissä muuttujan l tyyppi ei ole Line vaan "mikä tahansa, mikä toteuttaa traitin Drawable".

Toteutus tässä on se, että muuttuja l on todellisuudessa kaksi osoitinta. Yksi, joka osoittaa muuttuujaan line ja siellä oleviin kuuteentoista tavuun dataan. Ja toinen, joka osoittaa staattisella alueella sijaitsevaan "vtableen" (virtual table). Vtable luodaan jokaiselle tyypille jokaiselle traitille, ja se siältää osoittimet tuon tyypin funktioille.

Esimerkissämme stattisella muistialueella on siis osoitin fuktioon Draw() tyypille Line. Kaikissa traitin Drawable vtableissa on samassa paikassa osoitin funktioon Draw(), eri tyypeillä on vain eri vtable.

Kun siis kutsutaan seuraavaa Draw()-funtiota

x.Draw();  // x tyyppiä &dyn Drawable

Ensin otetaan muuttujasta x se osa, joka osoittaa vtableen. vtablen vakiokohdasta, esim. tavu 16 löytyy osoite funktioon Draw Line-tyypin traitista Drawable. Tälle annetaan parametreiksi muuttujan x toisen osan pointterin päästä löytyvä muuttuja.

Kaikki tapahtuu erittäin tehokkaasti kun kaikki raskas päättely tehdään jo käännösaikana.

Heterogeeninen lista

Nyt maillä on ainekset koossa heterogeeniseen listaan.

Yksinkertaisesti teemme listan, jossa on viittauksia objekteihin, joiden tyyppi toteuttaa Drawable-traitin.

let v : Vec<&dyn Drawable> = vec![&line, &square, &line, &line];
for x in v.iter() {
    x.draw()
}

Objektilla on siis oltava todellinen muistia varaava implementaatio valmiina muualla.

Esimerkkikoodi

Ja tässä vielä kokonaisuudessaan esimerkkikoodi heterogeenisten listojen käytöstä.

#![allow(dead_code)]

struct Line {
    x1: i32,
    x2: i32,
    y1: i32,
    y2: i32,
}

struct Square {
    x: i32,
    y: i32,
    width: i32,
    height: i32,
}

trait Drawable {
    fn draw(&self);
}

impl Drawable for Line {
    fn draw(&self) {
        println!("line");
    }
}

impl Drawable for Square {
    fn draw(&self) {
        println!("square");
    }
}

trait Executable {
    fn tick(&self);
}

impl Executable for Square {
    fn tick(&self) {
        println!("square tick");
    }
}

fn main() {
    let line = Line{ x1: 1, x2: 2, y1: 3, y2: 4, };
    let l: &dyn Drawable = &line;
    l.draw();
    let square = Square{ x: 1, y: 2, width: 3, height: 4, };
    let v : Vec<&dyn Drawable> = vec![&line, &square, &line, &line];
    let e : Vec<&dyn Executable> = vec![&square, &square, &square];
    for x in v.iter () {
        x.draw();
    };
    for x in e.iter () {
        x.tick();
    };
}