7.1 Expressividade
7.1.1 Legibilidade
Uma vez que os paradigmas procedimental e orientado a objetos são imperativos, nestes o programador precisa definir exatamente como quer que o programa seja computado, explicitando, além da lógica do algoritmo, todo o fluxo de controle e manipulação dos dados. Por sua vez, nas abordagens dos paradigmas funcional e lógico, que são declarativos, o programador delega ao sistema todo o fluxo de controle da execução do programa, permitindo o mesmo apenas declarar as estruturas e funções necessárias para a solução do problema.
Baseado nesse fato podemos argumentar que a abordagem imperativa faz com que um programa seja mais difícil de ler se comparado a declarativa, pois uma vez que é necessário explicitar sobre qual fluxo de controle o programa deve ser computado, parte do código passar a ser mais para a máquina do que para o ser humano, o que pode inclusive dificultar a compreensão deste quanto a ideia do algoritmo em si, uma vez que para o ser humano compreender o processo de resolução de um problema, nem sempre é preciso explicitar todo o fluxo, bastando uma abstração adequada. Como exemplo, podemos usar os Exemplos 1 e 2, que objetivam listar todos os binários de 3 dígitos.
Exemplo 1:
include
int main()
{
for(int A=0; A<2; A++)
for(int B=0; B<2; B++)
for(int C=0; C<2; C++)
printf("%d%d%d\n",A,B,C);
return 0;
}
Exemplo 2:
dígito(0).
dígito(1).
binário(N) :- N=(A,B,C), dígito(A), dígito(B), dígito(C).
Vale ressaltar também que, como na programação declarativa os dados são imutáveis, o programa é livre de efeitos colaterais e possui transparência referencial, uma vez que você entende como algo funciona, você não precisa mais se preocupar se em algum momento especifico pode acontecer um erro naquele código caso o programa seja executado de alguma forma não planejada. Essa característica torna a leitura do código muito mais simples, uma vez que o resultado de todas as partes do programa não compartilham estados em comum e são independentes das demais partes (e de sua ordem de execução), permitindo o programador se concentrar em entender apenas a parte desejada, abstraindo as demais partes, sem receio de que o não conhecimento delas possa facilitar um erro.
7.1.2 Capacidade de Escrita
Podemos também analisar a expressividade dos paradigmas quanto à quantidade de esforço necessário para se escrever um programa nele, ou seja, por quão fácil é usar um determinado paradigma para representar um problema e sua solução.
Cada paradigma foi feito para expressar de forma simples e natural um tipo de problema, consequentimente um programa relativamente simples pode se tornar complexo num paradigma inapropriado. O Exemplo 58, 59, 60 e 61 ilustram o clássico programa Hello Word em cada um dos paradigmas apresentados: procedimental, orientado a objetos, funcional e lógico, respectivamente.
Exemplo 58:
void main()
{
printf("Hello, world");
}
Exemplo 59:
public class HelloWorld
{
public static void main()
{
Console.Write("Hello, World");
}
}
Exemplo 60:
print "Hello, Word"
Exemplo 61:
?- writeln('Hello World').
Note que nos paradigmas imperativos foi necessário declarar não só a instrução de mostrar a mensagem na tela, mas as também estruturas necessárias para o paradigma: para o procedimental uma função e para o orientado a objetos uma classe e um método, exigindo também conceitos de modificadores de acesso, que é um conceito plenamente desnecessário para um programa simplista como o Hello Word.
Como temos visto, o paradigma declarativo permite ao programador tratar a programação de forma mais abstrata, o que além de deixar o código mais expressivo e simples, também exige menos esforço durante a codificação. Como demonstração, o Exemplo 62, 63 e 64 ilustram como seria um programa que recebe um conjunto de números e retorna o maior nos paradigmas procedimental, funcional e lógico, respectivamente. Note que o paradigma orientado a objetos não aparece no exemplo, pois seu código seria igual ao do procedimental, apenas estando dentro de uma classe (como no caso do programa Hello Word).
Exemplo 62:
int maxList(int[] array, int size)
{
var aux = array[0];
for (var i = 1; i < size; i++)
if (array[i] > aux)
aux = array[i];
return aux;
}
Exemplo 63:
maxList (x:[]) = x
maxList (x:xs) = if x >= m then x else m
where m = maxList xs
Exemplo 64:
maxList([X],X).
maxList([X|Xs],X):- maxList(Xs,M), X >= M.
maxList([X|Xs],M):- maxList(Xs,M), M > X.
Note que além da definição nos paradigmas funcional e lógico ser mais concisos, o programa consegue não só para encontrar o maior valor de um conjunto de inteiros, mas também para qualquer tipo comparável (caractere, cadeia, reais, booleanos, etc.), pois usa polimorfismo paramétrico, que não é um conceito presente na programação procedimental (mas presente na orientação a objetos), nesta, o programador seria obrigado a criar outra função para cada tipo, dificultando assim a escrita do programa.
Conforme vimos no capitulo cinco e nos Exemplos 62, 63 e 64, muitas técnicas e conceitos do paradigma funcional simplificam consideravelmente a escrita do código, como: polimorfismo paramétrico (presente também na orientação a objetos, mas introduzido pelo paradigma funcional), funções como objetos de primeira classe e expressões lambdas.
Em algumas linguagens imperativas como a linguagem C, é possível simular funções de ordem superior através do uso de ponteiros. Abaixo os Exemplos 65, 66 e 67 demonstram essa situação em linguagem C, seguido de seus correspondentes em Haskell e Prolog, respectivamente.
Exemplo 65:
void map(int (*f)(int), int x[], int size)
{
for (int i = 0; i < size; i++)
x[i] = f(x[i]);
}
int f(int x)
{
return 3 * x + 1;
}
int main()
{
int list[] = {1, 2, 3, 4, 5};
map(f, list, 5);
}
Exemplo 66:
map f [] = []
map f (x:xs) = f x : map f xs
map (\x -> 3 * x + 1) [1, 2, 3, 4, 5]
Exemplo 67:
f(X,Y) :- Y is X * 3 + 1.
map([],F,[]).
map([X|Xs],F,[Y|Ys]) :- call(F,X,Y), map(Xs,F,Ys).
Contudo, note que além do código do paradigma procedimental exigir muito mais esforço de escrita que seus correspondentes, ainda seria necessário definir uma função map para cada tipo, já que polimorfismo paramétrico é um conceito funcional. Já no paradigma orientado a objetos, seria possível definindo a função map como uma função que recebe um vetor de T e um ponteiro de função que recebe T e devolve U, algo similar ao pseudocódigo abaixo:
Exemplo 68:
public class Program
{
public static U[] map(T[] oldArray, U (*f)(T))
{
int tamanho = oldArray.length;
U[] newArray = new U[tamanho];
for(int i = 0; i < tamanho; i++)
newArray[i] = f(oldArray[i]);
return newArray;
}
public static int f(int x)
{
return 3 * x + 1;
}
public static void Main()
{
var list = new int[] {1, 2, 3, 4, 5};
map(list, f);
}
}
É fácil notar que ainda assim o código ficou extremamente maior que o do paradigma funcional e lógico. Outro ponto relevante é que o uso de funções de ordem superior por si só podem aumentar em muito a expressividade do código. Um bom exemplo é imaginar um cenário onde temos uma lista é precisamos aplicar um filtro qualquer sobre essa, o que é uma atividade comum em qualquer paradigma. Os Exemplos 69 e 70 demonstram como seria esse trecho de código nos paradigmas orientado a objetos e no funcional, respectivamente.
Exemplo 69:
public static List<int> getPares(List<int> numeros)
{
var pares = new List<int>();
for (int i = 0; i < numeros.Count; i++)
if (numeros[i] % 2 == 0)
pares.Add(numeros[i]);
}
Exemplo 70:
filter f [] = []
filter f (x:xs) = if f x then x : filter f xs else filter f xs
getPares numeros = filter (\x -> mod x 2 == 0) numeros
Perceba que além do código do paradigma funcional ter ficado muito mais simples e conciso do que o orientado a objetos, seria também muito mais fácil criar novas funções de filtro se necessário; se quiséssemos criar uma função getImpares por exemplo, no paradigma funcional só precisaríamos adicionar mais uma linha para a nova função, enquanto que no orientado a objetos teríamos que criar outra função quase idêntica a getPares, apenas mudando a comparação do if, ou então fazer uso de polimorfismo paramétrico combinado com ponteiros de função (similar ao Exemplo 68) para definir uma função de filtro genérica, o que ainda assim, tornaria o código ainda menos expressivo e complexo.
Como último exemplo de comparação, os Exemplos 71, 72 e 73 definirem o algoritmo quicksort de forma imperativa, funcional e lógica, respectivamente:
Exemplo 71:
void quicksort(ref T[] list, int size)
{
if (size == 0)
return;
var smallerList = new T[size];
var largerList = new T[size];
T pivot = list[0];
int numSmaller = 0, numLarger = 0;
for (int i = 1; i < size; i++)
if (list[i] < pivot)
smallerList[numSmaller++] = list[i];
else
largerList[numLarger++] = list[i];
quicksort(ref smallerList, numSmaller);
quicksort(ref largerList, numLarger);
int pos = 0;
for (int i = 0; i < numSmaller; i++)
list[pos++] = smallerList[i];
list[pos++] = pivot;
for (int j = 0; j < numLarger; j++)
list[pos++] = largerList[j];
}
Exemplo 72:
filter f [] = []
filter f (x:xs) = if f x then x : filter f xs else filter f xs
quicksort [] = []
quicksort (x:xs) = quicksort lowers ++ [x] ++
quicksort largers
where lowers = filter (>=x) xs largers = filter (< x) xs
Exemplo 73:
concat([],Ys,Ys).
concat([X|Xs],Ys,[X|Z]) :- concat(Xs,Ys,Z).
partition([],Y,[],[]).
partition([X|Xs],Y,[X|Ls],Rs) :- X <= Y,partition(Xs,Y,Ls,Rs).
partition([X|Xs],Y,Ls,[X|Rs]) :- X > Y,partition(Xs,Y,Ls,Rs).
quicksort([],[]).
quicksort([X|Xs],Ys) :- partition(Xs,X,Left,Right),
quicksort(Left,Ls),
quicksort(Right,Rs),
append(Ls,[X|Rs],Ys).
Como demonstrado ao longo desta seção, fica evidente que programas declarativos, especialmente os funcionais, tendem a ser mais expressivos e concisos que seus correspondentes imperativos.