Introducció

Refactoritzar és modificar el codi perquè tingui una altre estructura, però que segueixi fent el mateix.

Però per qué necessitem refactoritzar un codi si aquest funciona? Després de tot al compilador i a l'ordinador no li importa si el codi costa d’entendre o no està ben estructurat. Però quan una persona ha de modificar un codi, primer ha d’entendre el codi, i molts cops és difícil d'entendre el que està escrit encara que ho haguem escrit nosaltres fa unes setmanes.

Exemple

Per desenvolupar aquesta activitat farem servir un exemple, però aquest exemple és de poques línies de codi perquè sigui fàcil d'entendre. Per tant, en una situació real tindria poca utilitat refactoritzar el codi perquè segur que la majoria el podem entendre i podem tolerar que el codi no s’adapti al nostre estil, però en molts casos no és així.

Farem servir d’exemple una empresa que proporciona màquines virtuals al núvol, i factura mensualment als clients a partir del total d’hores que ha utilitzat el recurs virtual que s’ha creat sota demanda.

Per modelitzar les dades farem servir llistes, tuples i diccionaris tal com pots veure a continuació:

# recursos (màquines virtuals)
resources = {
    # ResourceId: (Processor, price per hour, cores, GB of memory)
    'CAX11': ('Ampere', 0.0072, 2, 4),
    'CAX21': ('Ampere', 0.0124, 4, 8),
    'CX11': ('Intel', 0.0081, 1, 2),
    'CPX11': ('AMD', 0.0084, 2, 2),
}

# recursos utilitzats per un client
invoice = {
    'client': 'ACME',
    # (ResourceId, hours)
    'rentals': [('CAX21', 6),  ('CPX11', 58), ('CAX21', 376)]
}

El codi que haurem de refactoritzar construeix la factura mensual en format text del client:

Rental Record for ACME
    	CAX21   0.0744
    	CPX11   0.4872
    	CAX21   2.3312
Amount owed is 2.89

A continuació tens el codi d’exemple main.py:

# funció que genera el document de text d'una factura
def statement(invoice, resources):
    
    totalAmount = 0

    result = f"Rental Record for {invoice['client']}\n"

    for rental in invoice['rentals']:

        amount = 0
        resourceId = rental[0]
        resource = resources[resourceId]
        cpu = resource[0]
        price = resource[1]
        hours = rental[1]

        # determine amount for each resource rental
        match cpu:
            case 'Ampere':
                amount =  hours * price
                if hours > 240:
                    amount = amount / 2
            case 'AMD':
                amount = hours * price
                if hours > 360:
                    amount = amount / 2
            case 'Intel':
                amount = hours * price
                if hours > 360:
                    amount = amount / 2

            case _:
                msg = f"Unknow cpu: {cpu}"
                raise Exception(msg)
       
        # print figures for this rental
        result += f'\t{resourceId}\t{amount:.4f}\n'
        totalAmount += amount

    # add footer lines
    result += f'Amount owed is {totalAmount:.2f}\n'

    return result

# recursos (màquines virtuals)
resources = {
    # ResourceId: (Processor, price per hour, cores, GB of memory)
    'CAX11': ('Ampere', 0.0072, 2, 4),
    'CAX21': ('Ampere', 0.0124, 4, 8),
    'CX11': ('Intel', 0.0081, 1, 2),
    'CPX11': ('AMD', 0.0084, 2, 2),
}

# recursos utilitzats per un client
invoice = {
    'client': 'ACME',
    # (ResourceId, hours)
    'rentals': [('CAX21', 6),  ('CPX11', 58), ('CAX21', 376)]
}

result = statement(invoice, resources)
print(result)

El resultat que obtenim quan executem python3 main.py és l’esperat:

Rental Record for ACME
    	CAX21   0.0744
    	CPX11   0.4872
    	CAX21   2.3312
Amount owed is 2.89

Aquesta funció és un exemple d’una funció que potser és massa llarga, però que funciona. Per tant cal un bon motiu per dedicar-li el temps necessari per refactoritzar-la perquè

  • Si ningú ha de llegir i entendre el codi, no és un problema que el codi no estigui ben estructurat.

  • Si no s’han d’afegir noves característiques o depurar el codi, no és un problema que el codi sigui dificil d’entendre.

En el nostre cas tenim un motiu per refactoritzar el codi: A més de crear una factura en format text volem que es pugui crear una factura en format HTML

<h1>Rental Record for <em>ACME</em></h1>
<table>
  <tr><td>CAX21</td><td>0.0744</td></tr>
  <tr><td>CPX11</td><td>0.4872</td></tr>
  <tr><td>CAX21</td><td>2.3312</td></tr>
</table>
<p>Amount owed is <em>2.89</em></p>

Refactoritzar

El primer que hem de fer es refactoritzar la funció perquè puguem afegir la nova funcionalitat.

Descomposar la funció

Quan treballes en una funció molt llarga, el primer que has de fer és buscar blocs lògics de codi per convertirlos en funcions, i el primer bloc de codi que crida fàcilment l’atenció és la sentència match, que la podem convertir en la funció amountFor:

def statement(invoice, resources):
  
   totalAmount = 0

   result = f"Rental Record for {invoice['client']}\n"

   for rental in invoice['rentals']:

       amount = 0
       resourceId = rental[0]
       resource = resources[resourceId]
       cpu = resource[0]
       price = resource[1]
       hours = rental[1]

       # determine amount for each resource rental
       amount = __amountFor(rental,resources)
     
       # print figures for this rental
       result += f'\t{resourceId}\t{amount:.4f}\n'
       totalAmount += amount

   # add footer lines
   result += f'Amount owed is {totalAmount:.2f}\n'

   return result

def __amountFor(rental,resources):
  
   amount = 0
   resourceId = rental[0]
   resource = resources[resourceId]
   cpu = resource[0]
   price = resource[1]
   hours = rental[1]

   # determine amount for each resource rental
   match cpu:
       case 'Ampere':
           amount =  hours * price
           if hours > 240:
               amount = amount / 2
       case 'AMD':
           amount = hours * price
           if hours > 360:
               amount = amount / 2
       case 'Intel':
           amount = hours * price
           if hours > 360:
               amount = amount / 2
       case _:
           msg = f"Unknow cpu: {cpu}"
           raise Exception(msg)
  
   return amount

Com pots veure en el codi, tambè hem de copiar les variable temporals que hi ha abans de la sentència match.

Pots verificar que fa el mateix que abans executant el programa main.py.

A continuació hem d’eliminar les variables temporal que ja no fem servir:

def statement(invoice, resources):
  
   totalAmount = 0

   result = f"Rental Record for {invoice['client']}\n"

   for rental in invoice['rentals']:

       resourceId = rental[0]

       amount = __amountFor(rental,resources)
     
       # print figures for this rental
       result += f'\t{resourceId}\t{amount:.4f}\n'
       totalAmount += amount

   # add footer lines
   result += f'Amount owed is {totalAmount:.2f}\n'

   return result

def __amountFor(rental,resources):
  
   amount = 0
   resourceId = rental[0]
   resource = resources[resourceId]
   cpu = resource[0]
   price = resource[1]
   hours = rental[1]

   # determine amount for each resource rental
   match cpu:
       case 'Ampere':
           amount =  hours * price
           if hours > 240:
               amount = amount / 2
       case 'AMD':
           amount = hours * price
           if hours > 360:
               amount = amount / 2
       case 'Intel':
           amount = hours * price
           if hours > 360:
               amount = amount / 2
       case _:
           msg = f"Unknow cpu: {cpu}"
           raise Exception(msg)
  
   return amount

Eliminar variables temporals

A continuació hem d’eliminar les variables temporals dins del bucle for:

def statement(invoice, resources):
  
   totalAmount = 0

   result = f"Rental Record for {invoice['client']}\n"

   for rental in invoice['rentals']:
       # print figures for this rental
       result += f'\t{rental[0]}\t{__amountFor(rental,resources):.4f}\n'
       totalAmount += __amountFor(rental,resources)

   # add footer lines
   result += f'Amount owed is {totalAmount:.2f}\n'

   return result

D’aquesta manera podem dividir el bucle perquè s’estan executant dos lògiques diferents: generar el text de la factura i computar el cost total.

def statement(invoice, resources):
  
   totalAmount = 0

   result = f"Rental Record for {invoice['client']}\n"

   for rental in invoice['rentals']:
       result += f'\t{rental[0]}\t{__amountFor(rental,resources):.4f}\n'

   for rental in invoice['rentals']:
       totalAmount += __amountFor(rental,resources)

   # add footer lines
   result += f'Amount owed is {totalAmount:.2f}\n'

   return result

Alguns programadors poden estar molt preocupats pel rendiment del programa si refactoritzem d’aquesta manera, i si és el teu cas, pots llegir aquest article que parla del rendiment dels programes: Yet Another Optimization Article

Com que hem dividit el bucle ara podem crear la funció totalAmount:

def statement(invoice, resources):
  
   totalAmount = __totalAmount(invoice,resources)

   result = f"Rental Record for {invoice['client']}\n"

   for rental in invoice['rentals']:
       result += f'\t{rental[0]}\t{__amountFor(rental,resources):.4f}\n'

   # add footer lines
   result += f'Amount owed is {totalAmount:.2f}\n'

   return result

def __totalAmount(invoice, resources):
   totalAmount = 0
   for rental in invoice['rentals']:
       totalAmount += __amountFor(rental,resources)
   return totalAmount

I a continuació eliminar la variable temporal totalAmount:

def statement(invoice, resources):

   result = f"Rental Record for {invoice['client']}\n"
   for rental in invoice['rentals']:
       result += f'\t{rental[0]}\t{__amountFor(rental,resources):.4f}\n'
   result += f'Amount owed is {__totalAmount(invoice,resources):.2f}\n'

   return result

D’aquesta manera tenim un codi refactoritzat en què la funció statement només s’encarrega de generar la factura en format text, i podem començar a afegir la nova funcionalitat: que també generi la factura en format html.

def statement(invoice, resources):

   result = f"Rental Record for {invoice['client']}\n"
   for rental in invoice['rentals']:
       result += f'\t{rental[0]}\t{__amountFor(rental,resources):.4f}\n'
   result += f'Amount owed is {__totalAmount(invoice,resources):.2f}\n'

   return result

def __totalAmount(invoice, resources):
   result = 0
   for rental in invoice['rentals']:
       result += __amountFor(rental,resources)
   return result

def __amountFor(rental,resources):
  
   result = 0

   resourceId = rental[0]
   resource = resources[resourceId]
   cpu = resource[0]
   price = resource[1]

   hours = rental[1]

   # determine amount for each resource rental
   match cpu:
       case 'Ampere':
           result =  hours * price
           if hours > 240:
               result = result / 2
       case 'AMD':
           result = hours * price
           if hours > 360:
               result = result / 2
       case 'Intel':
           result = hours * price
           if hours > 360:
               result = result / 2
       case _:
           msg = f"Unknow cpu: {cpu}"
           raise Exception(msg)
  
   return result

Nova funcionalitat

Un cop que hem refactoritzat el codi és molt fàcil afegit la nova funcionalitat:

def htmlStatement(invoice, resources):

   result = f"<h1>Rental Record for <em>{invoice['client']}<em></h1>\n"
   result += "<table>\n"
   for rental in invoice['rentals']:
       result += f'  <tr><td>{rental[0]}</td><td>{__amountFor(rental,resources):.4f}</td></tr>\n'
   result += "</table>\n"
   result += f'<p>Amount owed is <em>{__totalAmount(invoice,resources):.2f}</em></p>\n'

   return result

Com pots observar, si el codi hagués estat ben format la nova funcionalitat es podia implementar en cinc minuts.

Però tenir un codi ben format també necessita temps.

Perdre el temps

La funció privada __amountFor es pot refactoritzar, però que estaria bé refactoritzar no vol dir que s’hagi de refactoritzar. Si ningú l’ha de tornar a llegir o modificar és perdre el temps. Funciona, i punt.

Però com exercici educatiu la podem refactorizar.

El primer pas és eliminar les variables temporals fent servir funcions internes:

def __amountFor(rental,resources):
  
   result = 0

   def hours(): return rental[1]   
   def cpu():
       resourceId = rental[0]
       resource = resources[resourceId]
       return resource[0]
   def price():
       resourceId = rental[0]
       resource = resources[resourceId]
       return resource[1]

   match cpu():
       case 'Ampere':
           result =  hours() * price()
           if hours() > 240:
               result = result / 2
       case 'AMD':
           result = hours() * price()
           if hours() > 360:
               result = result / 2
       case 'Intel':
           result = hours() * price()
           if hours() > 360:
               result = result / 2
       case _:
           msg = f"Unknow cpu: {cpu()}"
           raise Exception(msg)
  
   return result

I podem eliminar les variable temporals de les funcions internes:

def __amountFor(rental,resources):
  
   result = 0

   def hours(): return rental[1]

   def resource(): return resources[rental[0]]
   def cpu(): return resource()[0]
   def price():return resource()[1]

   match cpu():
       case 'Ampere':
           result =  hours() * price()
           if hours() > 240:
               result = result / 2
       case 'AMD':
           result = hours() * price()
           if hours() > 360:
               result = result / 2
       case 'Intel':
           result = hours() * price()
           if hours() > 360:
               result = result / 2
       case _:
           msg = f"Unknow cpu: {cpu()}"
           raise Exception(msg)
  
   return result

Ha valgut la pena? Si no fem res més la veritat és que no.

Saber-ne més