Julia é uma linguagem de tipagem dinâmica com um compilador just-in-time. Isso significa que você não precisa compilar seu programa antes de executá-lo, como precisaria fazer com C++ ou FORTRAN. Em vez disso, Julia pegará seu código, adivinhará os tipos quando necessário e compilará partes do código antes de executá-lo. Além disso, você não precisa especificar explicitamente cada tipo. Julia vai inferir os tipos para você na hora.
As principais diferenças entre Julia e outras linguagens dinâmicas como R e Python são: Primeiro, Julia permite ao usuário especificar declarações de tipo. Você já viu algumas declarações de tipo em Por que Julia? (Section 2): eles são aqueles dois pontos duplos ::
que às vezes vem depois das variáveis. No entanto, se você não quiser especificar o tipo de suas variáveis ou funções, Julia terá o prazer de inferir (adivinhar) para você.
Em segundo lugar, Julia permite que os usuários definam o comportamento da função de acordo com combinações diversas de tipos de argumento por meio do despacho múltiplo. Também falamos sobre despacho múltiplo em Section 2.3. Por meio do despacho múltiplo, nós definimos um comportamento diferente de uma função para um determinado tipo quando escrevemos uma nova função com o mesmo nome da função anterior, mas cuja assinatura contém a especifcação deste tipo em seus argumentos.
As variáveis são valores que você diz ao computador para armazenar com um nome específico, para que você possa recuperar ou alterar seu valor posteriormente. Julia tem diversos tipos de variáveis, mas, em ciência de dados, usamos principalmente:
Int64
Float64
Bool
String
Inteiros e números reais são armazenados usando 64 bits por padrão, é por isso que eles têm o sufixo 64
no nome do tipo. Se você precisar de mais ou menos precisão, existem os tipos Int8
ou Int128
, por exemplo, nos quais um maior número significa uma maior precisão. Na maioria das vezes, isso não será um problema, então você pode simplesmente seguir os padrões.
Criamos novas variáveis escrevendo o nome da variável à esquerda e seu valor à direita, e no meio usamos o operador de atribuição =
. Por exemplo:
name = "Julia"
age = 9
9
Observe que a saída de retorno da última instrução (idade
) foi impressa no console. Aqui, estamos definindo duas novas variáveis: nome
e idade
. Podemos recuperar seus valores digitando os nomes dados na atribuição:
name
Julia
Se quiser definir novos valores para uma variável existente, você pode repetir os passos realizados durante a atribuição. Observe que Julia agora substituirá o valor anterior pelo novo. Suponho que o aniversário de Julia já passou e agora fez 10 anos:
age = 10
10
Podemos fazer o mesmo com name
. Suponha que Julia tenha ganho alguns títulos devido à sua velocidade incrível. Mudaríamos a variável name
para o novo valor:
name = "Julia Rapidus"
Julia Rapidus
Também podemos fazer operações em variáveis como adição ou divisão. Vamos ver quantos anos Julia tem, em meses, multiplicando age
por 12:
12 * age
120
Podemos inspecionar os tipos das variáveis usando a função typeof
:
typeof(age)
Int64
A próxima pergunta então se torna: “O que mais posso fazer com os inteiros?” Há uma função boa e útil, methodswith
que expõe todas as funções disponíveis, junto com sua assinatura, para um certo tipo. Aqui, vamos restringir a saída às primeiras 5 linhas:
first(methodswith(Int64), 5)
[1] logmvbeta(p::Int64, a::T, b::T) where T<:Real in StatsFuns at /home/runner/.julia/packages/StatsFuns/5v7Lq/src/misc.jl:22
[2] logmvbeta(p::Int64, a::Real, b::Real) in StatsFuns at /home/runner/.julia/packages/StatsFuns/5v7Lq/src/misc.jl:23
[3] logmvgamma(p::Int64, a::Real) in StatsFuns at /home/runner/.julia/packages/StatsFuns/5v7Lq/src/misc.jl:8
[4] read(t::HTTP.ConnectionPool.Transaction, nb::Int64) in HTTP.ConnectionPool at /home/runner/.julia/packages/HTTP/aTjcj/src/ConnectionPool.jl:232
[5] write(ctx::MbedTLS.MD, i::Union{Float16, Float32, Float64, Int128, Int16, Int32, Int64, UInt128, UInt16, UInt32, UInt64}) in MbedTLS at /home/runner/.julia/packages/MbedTLS/4YY6E/src/md.jl:140
Ter apenas variáveis à disposição, sem qualquer forma de hierarquia ou relacionamento não é o ideal. Em Julia, podemos definir essa espécie de dado estruturado com um struct
(também conhecido como tipo composto). Dentro de cada struct
, você pode especificar um conjunto de campos field
s. Eles diferem dos tipos primitivos (por exemplo, inteiro e flutuantes) que já são definidos por padrão dentro do núcleo da linguagem Julia. Já que a maioria dos struct
são definidos pelo usuário, eles são conhecidos como tipos definidos pelo usuário.
Por exemplo, vamos criar um struct
para representar linguagens de programação científica em código aberto. Também definiremos um conjunto de campos junto com os tipos correspondentes dentro do struct
:
struct Language
name::String
title::String
year_of_birth::Int64
fast::Bool
end
Para inspecionar os nomes dos campos, você pode usar o fieldnames
e passar o struct
desejado como argumento:
fieldnames(Language)
(:name, :title, :year_of_birth, :fast)
Para usar os struct
, devemos instanciar instâncias individuais (ou “objetos”), cada um com seus próprios valores específicos para os campos definidos dentro do struct
. Vamos instanciar duas instâncias, uma para Julia e outra para Python:
julia = Language("Julia", "Rapidus", 2012, true)
python = Language("Python", "Letargicus", 1991, false)
Language("Python", "Letargicus", 1991, false)
Algo importante de se notar com os struct
é que não podemos alterar seus valores uma vez que são instanciados. Podemos resolver isso com mutable struct
. Além disso, observe que objetos mutáveis geralmente serão mais lentos e mais propensos a erros. Sempre que possível, faça com que tudo seja imutável. Vamos criar uma mutable struct
.
mutable struct MutableLanguage
name::String
title::String
year_of_birth::Int64
fast::Bool
end
julia_mutable = MutableLanguage("Julia", "Rapidus", 2012, true)
MutableLanguage("Julia", "Rapidus", 2012, true)
Suponha que queremos mudar o campo título do objeto julia_mutable
. Agora podemos fazer isso já que julia_mutable
é um mutable struct
instanciado:
julia_mutable.title = "Python Obliteratus"
julia_mutable
MutableLanguage("Julia", "Python Obliteratus", 2012, true)
Agora que cobrimos os tipos, podemos passar para os operadores booleanos e a comparação numérica.
Nós temos três operadores booleanos em Julia:
!
: NOT&&
: AND||
: ORAqui estão exemplos com alguns deles:
!true
false
(false && true) || (!false)
true
(6 isa Int64) && (6 isa Real)
true
Com relação à comparação numérica, Julia tem três tipos principais de comparações:
Aqui temos alguns exemplos:
1 == 1
true
1 >= 10
false
As comparações funcionam até mesmo entre tipos diferentes:
1 == 1.0
true
Também podemos misturar e combinar operadores booleanos com comparações numéricas:
(1 != 10) || (3.14 <= 2.71)
true
Agora que já sabemos como definir variáveis e tipos personalizados como struct
, vamos voltar nossa atenção para as funções. Em Julia, uma função mapeia os valores de seus argumentos para um ou mais valores de retorno. A sintaxe básica é assim:
function function_name(arg1, arg2)
result = stuff with the arg1 and arg2
return result
end
A declaração de funções começa com a palavra-chave function
seguida do nome da função. Então, entre parênteses ()
, nós definimos os argumentos separados por uma vírgula ,
. Dentro da função, especificamos o que queremos que Julia faça com os parâmetros que fornecemos. Todas as variáveis que definimos dentro de uma função são excluídas após o retorno da função. Isso é bom porque é como se realizasse uma limpeza automática. Depois que todas as operações no corpo da função forem concluídas, instruímos Julia a retornar o resultado com o comando return
. Por fim, informamos a Julia que a definição da função terminou com a palavra-chave end
.
Existe também a maneira compacta de definição de funções por meio da forma de atribuição:
f_name(arg1, arg2) = stuff with the arg1 and arg2
É a mesma função que antes, mas definida de uma forma diferente, mais compacta. Como regra geral, quando seu código pode caber facilmente em uma linha de até 92 caracteres, a forma compacta é adequada. Caso contrário, basta usar o formato mais longo com a palavra-chave function
. Vamos mergulhar em alguns exemplos.
Vamos criar uma nova função que soma números:
function add_numbers(x, y)
return x + y
end
add_numbers (generic function with 1 method)
Agora, podemos usar nossa função add_numbers
:
add_numbers(17, 29)
46
E ela também funciona com números reais (também chamados em programação de números de ponto-flutuante ou, de forma mais curta, com o jargão “floats”):
add_numbers(3.14, 2.72)
5.86
Além disso, podemos definir comportamentos especializados para nossa função, por meio da especificação de declarações de tipo. Suponha que queremos ter uma função round_number
que se comporta de maneira diferente se seu argumento for um Float64
ou Int64
:
function round_number(x::Float64)
return round(x)
end
function round_number(x::Int64)
return x
end
round_number (generic function with 2 methods)
Podemos ver que ela é uma função com múltiplos métodos:
methods(round_number)
round_number(x::Float64) in Main at none:1
round_number(x::Int64) in Main at none:5
Mas há um problema: o que acontece se quisermos arredondar um float de 32 bits, Float32
? Ou um inteiro de 8 bits, Int8
?
Se você quiser que algo funcione em todos os tipos de float e inteiros, você pode usar um tipo abstrato na assinatura de tipo, como AbstractFloat
ou Integer
:
function round_number(x::AbstractFloat)
return round(x)
end
round_number (generic function with 3 methods)
Agora, funcionará da forma esperada com qualquer tipo de float:
x_32 = Float32(1.1)
round_number(x_32)
1.0
OBSERVAÇÃO: Podemos inspecionar tipos com as funções
supertypes
esubtypes
.
Vamos voltar ao nosso struct
Language
que definimos anteriormente. Será um exemplo de despacho múltiplo. Vamos estender a função Base.show
que imprime a saída de tipos instanciados e de struct
.
Por padrão, uma struct
tem um output básico, que você pôde observar do caso do python
. Podemos definir um novo método Base.show
para nosso tipo Language
, assim temos uma boa impressão para nossas instâncias de linguagens de programação. Queremos comunicar claramente os nomes, títulos e idades em anos das linguagens de programação. A função Base.show
aceita como argumentos um tipo IO
chamado io
seguido pelo tipo para o qual você deseja definir o comportamento personalizado:
Base.show(io::IO, l::Language) = print(
io, l.name, " ",
2021 - l.year_of_birth, ", years old, ",
"has the following titles: ", l.title
)
Agora, vamos ver como o output de python
será:
python
Python 30, years old, has the following titles: Letargicus
Uma função também pode retornar dois ou mais valores. Veja a nova função add_multiply
abaixo:
function add_multiply(x, y)
addition = x + y
multiplication = x * y
return addition, multiplication
end
add_multiply (generic function with 1 method)
Nesse caso, podemos fazer duas coisas:
Podemos, analogamente aos valores de retorno, definir duas variáveis para conter os valores de retorno da função, uma para cada valor de retorno:
return_1, return_2 = add_multiply(1, 2)
return_2
2
Ou podemos definir apenas uma variável para manter os valores de retorno da função e acessá-los com first
ou last
:
all_returns = add_multiply(1, 2)
last(all_returns)
2
Algumas funções podem aceitar argumentos de palavra-chave ao invés de argumentos posicionais. Esses argumentos são como argumentos comuns, exceto pelo fato de serem definidos após os argumentos de função regulares e separados por um ponto e vírgula ;
. Por exemplo, vamos definir uma função logarithm
que por padrão usa base \(e\) (2.718281828459045) como um argumento de palavra-chave. Perceba que aqui estamos usando o tipo abstrato Real
para que possamos cobrir todos os tipos derivados de Integer
e AbstractFloat
, dado que ambos são subtipos de Real
:
AbstractFloat <: Real && Integer <: Real
true
function logarithm(x::Real; base::Real=2.7182818284590)
return log(base, x)
end
logarithm (generic function with 1 method)
Funciona sem especificar o argumento base
já que fornecemos um valor de argumento padrão na declaração da função:
logarithm(10)
2.3025850929940845
E também com o argumento de palavra-chave base
diferente de seu valor padrão:
logarithm(10; base=2)
3.3219280948873626
Muitas vezes não nos importamos com o nome da função e queremos criar uma rapidamente. O que precisamos é das funções anônimas. Elas são muito usadas no fluxo de trabalho de ciência de dados em Julia. Por exemplo, quando usamos DataFrames.jl
(Section 4) ou Makie.jl
(Section 5), às vezes precisamos de uma função temporária para filtrar dados ou formatar os rótulos de um gráfico. É aí que usamos as funções anônimas. Elas são especialmente úteis quando não queremos criar uma função e uma instrução simples seria o suficiente.
A sintaxe é simples. Nós usamos o operador ->
. À esquerda do ->
definimos o nome do parâmetro. E à direita do ->
definimos quais operações queremos realizar no parâmetro que definimos à esquerda de ->
. Segue um exemplo. Suponha que queremos desfazer a transformação de log usando uma exponenciação:
map(x -> 2.7182818284590^x, logarithm(2))
2.0
Aqui, estamos usando a função map
para mapear convenientemente a função anônima (primeiro argumento) para logarithm(2)
(segundo argumento). Como resultado, obtemos o mesmo número, porque o logaritmo e a exponenciação são inversos (pelo menos na base que escolhemos – 2.7182818284590)
Na maioria das linguagens de programação, o usuário tem permissão para controlar o fluxo de execução do computador. Dependendo da situação, queremos que o computador faça uma coisa ou outra. Em Julia, podemos controlar o fluxo de execução com as palavras-chave if
, elseif
e else
. Estas são conhecidas como declarações condicionais.
A palavra-chave if
comanda Julia a avaliar uma expressão e, dependendo se ela é verdadeira (true
) ou falsa (false
), a executar certas partes do código. Podemos combinar várias condições if
com a palavra-chave elseif
para um fluxo de controle complexo. Assim, podemos definir uma parte alternativa a ser executada se qualquer coisa dentro de if
ouelseif
for avaliada como true
. Esse é o propósito da palavra-chave else
. Finalmente, como em todos os operadores de palavra-chave que vimos anteriormente, devemos informar a Julia quando a declaração condicional for concluída com a palavra-chave end
.
Aqui, temos um exemplo com todas as palavras-chave if
-elseif
-else
:
a = 1
b = 2
if a < b
"a is less than b"
elseif a > b
"a is greater than b"
else
"a is equal to b"
end
a is less than b
Podemos até envelopar isso em uma função chamada compare
:
function compare(a, b)
if a < b
"a is less than b"
elseif a > b
"a is greater than b"
else
"a is equal to b"
end
end
compare(3.14, 3.14)
a is equal to b
O clássico laço for em Julia segue uma sintaxe semelhante à das declarações condicionais. Você começa com a palavra-chave, nessa caso for
. Em seguida, você especifica o que Julia deve iterar sobre (ou, no jargão, “loopar”), p. ex., uma sequência. Além disso, como em tudo mais, você deve terminar com a palavra-chave end
.
Então, para fazer Julia imprimir todos os números de 1 a 10, você pode usar o seguinte laço for:
for i in 1:10
println(i)
end
O laço while é uma mistura das declarações condicionais anteriores com os laços for. Aqui, o laço é executado toda vez que a condição é avaliada como true
. A sintaxe segue a mesma forma da anterior. Começamos com a palavra-chave while
, seguido por uma declaração que é avaliada em true
ou false
. Como de costume, devemos terminar com a palavra-chave end
.
Segue um exemplo:
n = 0
while n < 3
global n += 1
end
n
3
Como pode ver, devemos usar a palavra-chave global
. Isso se deve ao escopo de variável. Variáveis definidas dentro das declarações condicionais, laços e funções existem apenas dentro delas. Isso é conhecido como o escopo da variável. Aqui, precisamos avisar Julia que o n
dentro do laço while
está no escopo global por meio do uso da palavra-chave global
.
Por fim, também usamos o operador +=
que é uma boa abreviatura para n = n + 1
.