La classe Matrix

21 Apr 2015

Iniziamo a costruire la nostra classe Matrix, partendo dalla definizione di una classe di errore specifica, un metodo di inizializzazione, un metodo di accesso agli elementi di una matrice e un metodo di salvataggio di un valore in una specificazione posizione della matrice.

La classe Matrix

Inizia il nostro dolorosissimo viaggio nella definizione di una classe Matrix (il nome di una classe deve sempre cominciare con la lettera maiuscola). Affinchè una classe di questo tipo possa considerarsi utilizzabile, vogliamo definire, come metodi public (cioè interfaccia della classe):

Non ci concentriamo per ora su altri metodi per motivi di tempo nello sviluppo di queste lezioni. Prima della nostra classe creiamo un paio di calssi di errore specifiche, ereditando i metodi dalle classi ArgumentError e RuntimeError:

class MatrixArgumentError < ArgumentError; end
class MatrixRuntimeError < RuntimeError; end

class Matrix
  # Inizializzazione
  def initialize()

  end

  # Accesso alla matrice
  def []()

  end

  # Assegnazione a elemento dela matrice
  def []=()

  end

  # Somma tra matrici
  def +()

  end

  # Differenza tra matrici
  def -()

  end

  # Prodotto con scalare
  # Prodotto con matrice
  def *()

  end

  # Traspsizione di matrici
  def t()

  end
end

Il design della classe

Il primo passo nello sviluppo della classe è la produzione di un design iniziale nel quale si possano riconoscere quelle che saranno le funzioni e le caratteristiche di cui avremo bisogno. A parte alcune considerazioni che saranno abbastanza banali, la prima che facciamo è: per motivi di efficienza del codice, costruiremo una matrice il cui indice è basato sulla indicizzazione Row-Major Order e Column-Major order, quindi la matrice avrà l’aspetto di un grandissimo vettore, di cui dovremmo conoscere la dimensione di riga e di colonna, e per accedere alla posizione di un elemento della matrice.

La scelta tra Row-Major e Column-Major non è facile, ma se consideriamo che una matrice ha bisogno dela funzione di trasposizione, possiamo switchre mediante un flag tra le due tipologie per poter accedere alla matrice, semplificando ulteriormente il peso computazionale del calcolo. Quindi le variabili che la nostra classe necessiterà sono:

Tipi di variabile e accesso

Le variabili di una classe possono essere di due tipi:

Per fare un esempio, immaginate di avere una classe Persona. Ogni istanza di classe è un oggetto Persona. Per ogni Persona può considerarsi variabile di istanza le variabili @nome e @cognome. Se vogliamo integrare alla classe Persona la possibilità di contare tutte le istanze di Persona create, possiamo aggiungere una variabile di classe chiamata @@contatore. Questa ultima variabile fa parte della classe e non delle singole istanze.

Per motivi di completezza è necessario citare anche le variabili locali, ovvero variabili senza chiocciola davanti che esistono solo all’interno dello “scope” in cui sono state create (funzione, blocco if, etc.).

Discorso molto importante: le caratteristiche di accesso ad una variabile. Una variabile (sia di istanza che di classe) non può essere acceduta normalmente da un codice esterno alla implementazione della classe: questo perchè in Ruby tutte le variabili sono di tipo private, fino a quando non si creano funzioni specifiche che ne permettano la letture (getter) o la scrittura (setter). Data una variabile @nome_variabile, getter e setter assumono in modo semplificato la forma:

class Esempio
  def initialize(n)
    @nome_variabile = n
  end

  # Implementazione di un getter
  def nome_variabile
    return @nome_variabile
  end

  # Implementazione di un setter
  def nome_variabile=(n)
    @nome_variabile = n
  end
end

Dove risiede il vantaggio? In realtà sono molteplici:

A volte scrivere getter e setter per tutte le variabili di istanza può essere lungo e noioso, quindi se per le variabili non si hanno esigenze particolari, esistono delle shortcut che definiscono setter e getter al posto nostro (nota bene: solo per le variabili di istanza, non per le variabili di classe). Tali metodi prendono come argomento un Symbol, che ci permette di puntare direttamente alla variabile di classe (ad esempio: per @variabile si usa :variabile). Tali scorciatoie sono:

Utilizzando tali scorciatoie, l’esempio precedente diventa:

class Classe
  def initialize(n)
    @nome_variabile = n
  end

  attr_accessor :nome_variabile
end

Accessibilità delle funzioni

Discorso molto simile per le funzioni: esistono delle funzioni che sono di classe e delle funzioni che sono invece relative all’istanza. Solitamente, il costruttore della classe è un metodo di classe. Nella lettura della documentazione su RubyDoc della classe Array, si possono distinguere i metodi di classe come ::try_convert dai metodi di istanza come #size.

A livello di implementazione, le due tipologie di metodi si esplicitano come segue:

class Classe
  @@variabile_di_classe = 0

  def initialize(n)
    @nome_variabile = n
    @@variabile_di_classe += 1
  end

  attr_accessor :nome_variabile

  def Classe.metodo_di_classe
    return @@variabile_di_classe
  end

  def metodo_di_istanza
    return @@variabile_di_classe * @nome_variabile
  end
end

# Nel codice possono essere chiamati cosi:
oggetto = Classe.new(10)

Classe.metodo_di_classe

oggetto.metodo_di_istanza

A differenza delle variabili, i metodi di classe sono tutti public, a meno che non siano specificati dopo la keyword private:

class Classe
  @@variabile_di_classe = 0

  def initialize(n)
    @nome_variabile = n
    @@variabile_di_classe += 1
  end

  # metodi publici
  attr_accessor :nome_variabile

  def metodo_di_istanza
    return @@variabile_di_classe * @nome_variabile
  end

  private
  def metodo_privato(n)
    @nome_variabile = 5 * n
  end
end

initialize

Una delle prime funzioni da scrivere sarà sicuramente la inizializzazione della matrice. Un inizializzatore della classe si definisce nella funzione initialize, epuò essere richiamata dal codice come Matrix.new. Il primo inizializzatore che creiamo genera un matrice della dimensione desiderata con tutti i valori pari al risultato di un blocco. Come scelta di design, l’accesso Row-Major o Column-Major deve essere trasparente per l’utente (sono tecnicismi che non lo devono interessare), quindi inizializziamo la nostra matrice sempre a Row-Major, per cambiare nel caso sia invocata una trasposizione.

def initialize(rows, cols = nil, value = 0)
  if not cols
    cols = rows
  end

  @row_major = true
  @rows = rows
  @cols = cols

  @matrix = []
  for i in 0...@rows
    for j in 0...@cols
      value = yield(i,j) if block_given?
      @matrix << value
    end
  end  
end

attr_reader :rows, :cols

Analizziamo nel dettaglio questo codice.

L’utente, in ingresso deve specificare almeno il numero di righe che avrà la matrice, tramite l’argomento rows. La specifica del numero di colonne è opzionale, e se non è specificato nessun valore per cols, si imposta cols = rows alla linea 3.

Si inizializzano le variabili di istanza, che valgono quindi solamente per un oggetto Matrix: sono una flag che ci indica lo stato della matrice (se in Row Major ordering o in Column Major ordering, rispettivamente se @row_major è true o false), una variabile che contiene il numero di righe e una che contiene il numero di colonne. Completa lo stato del nostro oggetto un Array @matrix vuoto.

Dalle righe 10 a 16, cominciamo a popolare la variabile @matrix. La funzione block_given? ritorna true se l’utente ha fornito in ingresso alla inizializzazione un blocco. Se è stato fatto, le variabili passate internamente al blocco sono la posizione riga, colonna in cui ci troviamo attualmente. Il valore ritornato dal blocco è inserito dentro l’Array @matrix alla linea 14. Il fatto il ciclo interno (linea 12) sia basato sulle colonne mentr il ciclo esterno (linea 11) sia basato sulle righe ci garantisce il rispetto della condizione di inserimento di valori all’interno dell’Array secondo il principio di Row Major ordering.

Subito dopo la definizione della funzione di inizializzazione, dichiariamo la visibilità delle variabili di istanza mediante la shortcut attr_reader. In questo caso mettiamo a disposizione del client (chi userà la istanza), solamente in lettura, le variabili @rows e @cols.

Sembra relativamente complicato, ma questa funzione garantisce una certa libertà nella dichiarazione di una nuova matrice. Alcuni esempi di utilizzo sono:

# Definizione di una matrice quadrata inizializzata tutta a 0
m = Matrix.new(5)

# Definizione di una matrice 3x4 inizializzata tutta a 0
m = Matrix.new(3, 4)

# Definizione di una matrice 3x4 inizializzata tutta a 2
m = Matrix.new(3, 4, 2)
m = Matrix.new(3, 4) { 2 }

# Definizione di una matrice con elemento m[i,j]
# che è funzione di i e j
m = Matrix.new(3, 4) { |i, j| Math::E(i) * j }

# Definizione di una matrice identità
m = Matrix.new(5) { |i,j| ( i == j ? 1 : 0 ) }

Ovviamente questa implementazione non risulta essere in alcun modo robusta. Non abbiamo modo di controllare che cosa l’utente sta passando in ingresso alla matrice. Inseriamo qualche metodo di controllo di argomento al fine di ottenere un inizializzatore più robusto.

  def initialize(rows, cols = nil, value = 0)
    raise MatrixArgumentError,
    "rows must be of Fixnum class, not #{rows.class}" if not rows.is_a?(Fixnum)
    if not cols
      cols = rows
    end
    raise MatrixArgumentError,
    "cols must be of Fixnum class, not #{cols.class}" if not cols.is_a?(Fixnum)

    @row_major = true
    @rows = rows
    @cols = cols

    @matrix = []
    for i in 0...@rows
      for j in 0...@cols
        value = yield(i,j) if block_given?
        raise MatrixArgumentError,
        "value must be of Numeric class, not #{value.class}" if not value.is_a?(Numeric)
        @matrix << value
      end
    end  
  end

  attr_reader :rows, :cols

Questa implementazione controlla sia i valori forniti in ingresso come argomenti, che il risultato della eventuale valutazione del blocco.

Altri costruttori: metodi di classe

Nell’esempio precedente abbiamo mostrato come sia possibile generare una matrice identità in una singola linea di codice. Un inizializzatore di matrici identità potrebbe essere una aggiunta molto interessante alla nostra classe, che in questo modo potrebbe generare direttamente tali matrici, dato un solo elemento in ingresso.

Un costruttore di questo tipo si specifica con un metodo di classe. Oltre al costruttore di matrici identità, possiamo specificare un costruttore di matrici random:

Il nome eye per la generazione di matrici identità deriva dalla omonima funzione Matlab.

def Matrix.eye(n)
  return Matrix.new(n) { |i,j| (i == j ? 1 : 0) }
end

def Matrix.rand(r, c = nil, range = (0.0)..(1.0))
  return Matrix.new(r, (c ? c : r)) { Random.rand(range) }
end

Accesso e assegnazione

Scriviamo delle funzioni che ci permettano di accedere ala matrice esattamente come un Array (ovvero utilizzando le parentesi quadre). Il comportamento che vogliamo ottenere è il seguente: se si fornisce un numero solo all’interno delle parentesi quadre, si entra nella matrice esattamente come gli Array, se si specificano due numeri, si entra nella matrice come riga e colonna, in funzione dell’ordinamento, come riga e colonna.

Introduciamo inoltre un metodo each, per poi includere il modulo Enumerable. I moduli sono collezioni di metodi utili che possono essere inclusi in una classe. Un esempio di modulo oltre Enumerable è il modulo Math che include le funzioni matematiche comuni.

Includendo Enumerable dopo aver definito each ci permette di ereditare diverse funzioni utili (come ad esempio each_with_index, etc.) che sono definite automaticamente (senza scrivere ulteriori righe di codice).

# Accesso alla matrice
def [](i, j = nil)
  if j
    raise MatrixArgumentError,
    "Row out of bound (#{i} > #{@rows - 1})" if i >= @rows
    raise MatrixArgumentError,
    "Col out of bound (#{j} > #{@cols - 1})" if j >= @cols
    if @row_major then
      return @matrix[i * @cols + j]
    else
      return @matrix[j * @rows + i]
    end
  else
    raise MatrixArgumentError,
    "Index out of bound (#{i} > #{@matrix.size - 1})" if i >= @matrix.size
    return @matrix[i]
  end
end

# Assegnazione a elemento dela matrice
def []=(i,j = nil, value)
  if j
    raise MatrixArgumentError,
    "Row out of bound (#{i} > #{@rows - 1})" if i >= @rows
    raise MatrixArgumentError,
    "Col out of bound (#{j} > #{@cols - 1})" if j >= @cols
    if @row_major then
    if @row_major then
      @matrix[i * @cols + j] = value
    else
      @matrix[j * @rows + i] = value
    end
  else
    raise MatrixArgumentError,
    "Index out of bound (#{i} > #{@matrix.size - 1})" if i >= @matrix.size
    @matrix[i] = value
  end
end

# Definizione del metodo each
def each
  for i in 0...@rows
    for j in 0...@cols
      yield(self[i,j])
    end
  end
  return self
end

# Inclusione di Enumerable
include Enumerable

Alcune funzioni non sono definite automaticamente. Continuando sulla falsa riga degli Array, probabilemente ci farebbe comodo avere funzioni che iterano attraverso gli indici di riga e colonna. Purtroppo questi metodi vanno definiti manualmente, sulla base del metodo each_with_index. Conoscendo la flag @row_major e l’indice al’interno della matrice, possiamo ricostruire la posizione di riga e di colonna. Siccome questa funzione dipende strettamente dalla configurazione interna, e potrebbe essere comodo utilizzarla più volte, la introduciamo sotto forma di funzione privata in fondo alla classe dopo la keyword private.

def each_with_indexes
  each_with_index { |e,i|
    row, col = get_indexes(i)
    yield(e, row, col)
  }
end

def map_each_with_indexes
  each_with_indexes { |e, row, col|
    self[row,col] = yield(e, row, col)
  }
end

# I metodi definiti dopo la keyword
# private sono privati per la classe
private
def get_indexes(n)
  raise MatrixArgumentError,
    "get_index(n): n must be Fixnum, not #{n.class}" if not n.is_a?(Fixnum)
  if @row_major then
    row = n / @cols
    col = n % @cols
  else
    row = n / @rows
    col = n % @rows
  end
  return row, col
end

Stampa della matrice

Ultimo metodo che definiamo in questa lezione, to_s. Questo metodo trasforma il contenuto della matrice in una stringa stampabile a schermo:

# Trasforma oggetto Matrix in stringa
def to_s
  for i in 0...@rows
    print "|"
    for j in 0...@cols
      print "\t#{self[i,j]}"
    end
    print "\t|\n"
  end
end

Da notare che avendo usato i metodi descritti in precedenza, questa funzione è robusta nei confronti del tipo di ordering in cui ci troviamo.

Riassumendo…

Una overview della classe fino a questo punto:

#!/usr/bin/env ruby

# matrix.rb

class MatrixArgumentError < ArgumentError; end
class MatrixRuntimeError < RuntimeError; end

class Matrix
  # Inizializzazione
  def initialize(rows, cols = nil, value = 0)
    raise MatrixArgumentError,
      "rows must be of Fixnum class, not #{rows.class}" if not rows.is_a?(Fixnum)
    if not cols
      cols = rows
    end
    raise MatrixArgumentError,
      "cols must be of Fixnum class, not #{cols.class}" if not cols.is_a?(Fixnum)

    @row_major = true
    @rows = rows
    @cols = cols

    @matrix = []
    for i in 0...@rows
      for j in 0...@cols
        value = yield(i,j) if block_given?
        raise MatrixArgumentError,
          "value must be of Numeric class, not #{value.class}" if not value.is_a?(Numeric)
        @matrix << value
      end
    end  
  end

  attr_reader :rows, :cols

  def Matrix.eye(n)
    return Matrix.new(n) { |i,j| (i == j ? 1 : 0) }
  end

  def Matrix.rand(r, c = nil, range = (0.0)..(1.0))
    return Matrix.new(r, (c ? c : r)) { Random.rand(range) }
  end

  # Accesso alla matrice
  def [](i, j = nil)
    if j
      raise MatrixArgumentError,
      "Row out of bound (#{i} > #{@rows - 1})" if i >= @rows
      raise MatrixArgumentError,
      "Col out of bound (#{j} > #{@cols - 1})" if j >= @cols
      if @row_major then
        return @matrix[i * @cols + j]
      else
        return @matrix[j * @rows + i]
      end
    else
      raise MatrixArgumentError,
      "Index out of bound (#{i} > #{@matrix.size - 1})" if i >= @matrix.size
      return @matrix[i]
    end
  end

  # Assegnazione a elemento dela matrice
  def []=(i,j = nil, value)
    if j
      raise MatrixArgumentError,
      "Row out of bound (#{i} > #{@rows - 1})" if i >= @rows
      raise MatrixArgumentError,
      "Col out of bound (#{j} > #{@cols - 1})" if j >= @cols
      if @row_major then
      if @row_major then
        @matrix[i * @cols + j] = value
      else
        @matrix[j * @rows + i] = value
      end
    else
      raise MatrixArgumentError,
      "Index out of bound (#{i} > #{@matrix.size - 1})" if i >= @matrix.size
      @matrix[i] = value
    end
  end

  def each
    for i in 0...@rows
      for j in 0...@cols
        yield(self[i,j])
      end
    end
    return self
  end
  include Enumerable

  def each_with_indexes
    each_with_index { |e,i|
      row, col = get_indexes(i)
      yield(e, row, col)
    }
  end

  def map_each_with_indexes
    each_with_indexes { |e, row, col|
      self[row,col] = yield(e, row, col)
    }
  end

  # Trasforma oggetto Matrix in stringa
  def to_s
    for i in 0...@rows
      print "|"
      for j in 0...@cols
        print "\t#{self[i,j]}"
      end
      print "\t|\n"
    end
  end

  private
  def get_indexes(n)
    raise MatrixArgumentError,
      "get_index(n): n must be Fixnum, not #{n.class}" if not n.is_a?(Fixnum)
    if @row_major then
      row = n / @cols
      col = n % @cols
    else
      row = n / @rows
      col = n % @rows
    end
    return row, col
  end
end