Functional programming concepts › functional-gdscript.pdf · What is functional programming?...

Preview:

Citation preview

Functional programming concepts

And applications on GDScript

George Marques

● Godot contributor since 2015● Member of the project’s leadership since 2017● Worked at Javary Games and IMVU (using Godot)

– Hired by Godot since November 2019● Co-author of the book “Godot Engine Game Development in 24

Hours”, published by Pearson● Responsible for the UWP port and typed GDScript

Topics

● Introduction to functional programming● Monad● Functional programming characteristics● Functions● Applications (in general and on GDScript)● Other resources

What is functional programming?

● “Everything is a function”● Declarative instead of imperative● Application of mathematical concepts● Avoids changing external state (pure functions)● Functions are also values (higher-order functions)

Let’s talk about monad?

“A monad is a monoid in an endofunctor category”

- Rúnar Bjarnason

Okay, let’s not talk about monad

Or will we?

Disadvantages

● Declarative code is not always the most readable● It’s hard to combine pure functions and intended side-effects● Immutability and recursion can lead to performance penalties

– The functional paradigm avoids loops in favor of recursion

Vantages

● Implementation of pure functions are easier to understand– Looking at a function signature may be enough to know what it does

● Easier to run things in parallel (no need for locks)● Testing functions in isolation is more straightforward

– Also easier to debug

Side effects

● By definition: changes in objects outside the function scope● Change of global state● Input and output

– Yes, this includes keyboard input and video output● Changes in the object that contains the function (instance members)

Immutability

● Variables are treated as constants– Forbidden to re-assign a value to a variable– Forbidden to change properties of an object

● (Okay to change a local object for the sake of initialization)

● Instead of changes, make copies– func nope(obj_to_change: Object) -> void– func yep(obj_to_update: Object) -> Object

Pure function

● Don’t have side effects● All parameters are immutable● A call with the same arguments return the same value every time● Similar to the mathematical definition

First-class functions

● Function is also a value– It can be assigned to a variable– It can be passed as arguments to other functions– It can be the return value of a function

● Everything that can be done with a value can also be done with a function– Including putting them in arrays and dictionaries

First-class functions in GDScript

● Well, we’re not there yet, but will soon™

FuncRef to the rescue

● Exclusive Godot type that allows a function reference● Then you can treat the reference as a value

– And call it later (with arguments)● Making it in GDScript:

var my_func = funcref(self, "my_method")

● Calling:

my_func.call_func("An argument")

Higher-order functions

● Functions are “first-order” by default● They’re higher-order if they receive a function as an argument● Or return a function as a result● It’s not mandatory that they are pure

– But we like them more if they are

Higher-order functions

● Examples– map– filter– reduce

Still not available in GDScript for now, but not hard to implement in script

Map

● Take an array and a unary function, returns a copy of the array with the function applied to every element

● Signature: func map(input: Array, function: FuncRef) -> Array● Equivalent to:

var my_arr -= [1, 2, 3]

var mapped -= []

for element in my_arr:

mapped.append(some_func(element))

Map

● Example 1func half(num: int) -> int:

return num / 2

func half_array(numbers: Array) -> Array:

return map(numbers, funcref(self, "half"))

func _ready() -> void:

var numbers -= [2, 4, 6]

var halves -= half_array(numbers) # [1, 2, 3]

Map

● Example 2func get_id(user: User) -> int:

return user.id

func list_user_ids() -> Array:

var users: Array = fetch_users()

return map(users, funcref(self, "get_id"))

Map

● Possible implementationfunc map(input: Array, function: FuncRef) -> Array:

var result -= []

result.resize(input.size())

for i in range(input.size()):

result[i] = function.call_func(input[i])

Filter

● Takes an array and a unary function, returns a new array with only the elements to which the function returns true

● Signature: func filter(input: Array, function: FuncRef) -> Array● Equivalent to:

var my_arr -= [1, 2, 3]

var filtered -= []

for element in my_arr:

if some_func(element):

filtered.append(element)

Filter

● Example 1func is_even(num: int) -> bool:

return num % 2 -= 0

func get_evens(numbers: Array) -> Array:

return filter(numbers, funcref(self, "is_even"))

func _ready() -> void:

var numbers -= [1, 2, 3, 4]

var evens -= get_evens(numbers) # [2, 4]

Filter

● Example 2func is_active(user: User) -> bool:

return user.active

func list_active_users() -> Array

var users: Array = fetch_users()

return filter(users, funcref(self, "is_active"))

Filter

● Possible implementationfunc filter(input: Array, function: FuncRef) -> Array:

var result -= []

for element in input:

if function.call_func(element):

result.append(element)

return result

Reduce

● Takes an array, a binary function, and a value, returns a single value with the function applied to each element and the previous result

● Signature: func reduce(input: Array, function: FuncRef, base)● Equivalent to:

var my_arr -= [1, 2, 3]

var reduced = base

for element in my_arr:

reduced = some_func(reduced, element)

Reduce

● Example 1func sum(num1: int, num2: int) -> int:

return num1 + num2

func array_sum(numbers: Array) -> int:

return reduce(numbers, funcref(self, "sum"), 0)

func _ready():

var numbers -= [1, 2, 3, 4]

var sum: int = array_sum(numbers) # 10

Reduce

● Example 2func larger(num1: int, num2: int) -> int:

return num1 if num1 > num2 else num2

func get_user_age(user: User) -> int:

return user.age

func get_highest_user_age() -> int:

var user_ages: Array = map(fetch_users(), \

funcref(self, "get_user_age"))

# Base value is omitted here

return reduce(user_ages, funcref(self, "larger"))

Reduce

● Possible implementationfunc reduce(input: Array, function: FuncRef, base = null):

var accumulator = base

var index -= 0

if not base and input.size() > 0:

accumulator = input[0]

index = 1

while index < input.size():

accumulator = function.call_func(accumulator, input[index])

index += 1

return accumulator

Lambda

● Function definition where a value (expression) is expected● Helps in cases where the function is simple or doesn’t need to be

reused in another context● Example:

var result -= map([2, 4, 6], x -> x / 2)

print(result) # [1, 2, 3]

Lambdas in GDScript

● Well, we’re not there yet, but will soon™

Seriously!

Let’s talk a bit about theory

I promise it’s only a bit

Functor

● (No need to flee, it’s not that hard)● Functor is a type that can be mapped, that is, there’s a map function

applicable to the type● Yes, that’s it● As we say, Array is a functor because it has* a map function

– *Technically the function isn’t in the Array class, but it doesn’t matter conceptually.

Monoid

● Monoid is a type that has a function with three properties:– Binary

● Has two parameters– Associative

● function(a, function(b, c)) -= function(function(a, b), c)

– Has a neutral (identity) element● function(a, identity) -= function(identity, a) -= a

● It doesn’t need to be commutative– (function(a, b) -= function(b, a))

Monoid

● Example: integers with sum as the function– Binary: 3 + 4 (two arguments: “3” and “4”)– Associative: (1 + 2) + 3 = 1 + (2 + 3) = 6– Identity element: 2 + 0 = 0 + 2 = 2

Monoid

● Array is also a monoid!● What’s the function that makes it a monoid? Concatenation:

– Binary: [1, 2] + [3, 4] -= [1,2,3,4]– Associative: ([1,2] + [3,4]) + [5,6] -= [1,2] + ([3,4] + [5,6]) -=

[1,2,3,4,5,6]

– Identity element: [] + [1,2] -= [1, 2] + [] -= [1, 2]

Monad

● Yes, let’s talk about monad!● As said: “A monad is a monoid in an endofunctor category”● In other words: Monad is a monoid that is also an endofunctor● In the context of programming, every functor is an endofunctor● Which means...

Array is monad!

● Yes, that’s it● It’s a functor (can be mapped)● It’s a monoid (regarding the concatenation function)● So it’s also a monad

Promise

● Encapsulates asynchronous operations● Allows to maintain the chain of operations while we wait the

promise to be fulfilled– That is, the asynchronous operation complete– The promise can be treated as the value that it contains

● Suppress side effects

Promise

● Example: HTTP request (promise implementation)class_name Promise

signal success(value) # probably want error and done signals too

var done: bool = false

func _success(value):

done = true

emit_signal("success", value)

Promise

● Example: HTTP request (kitten request class)var promise: Promise = null

func kitten_request(width: int, height: int) -> Promise:

var http -= HTTPRequest.new(); add_child(http)

http.request("http:-/placekitten.com/%d/%d" % [width, height])

http.connect("request_completed", self, "_on_request_completed")

promise = Promise.new()

return promise

func _on_request_completed(result, response_code, headers, body):

var image -= Image.new()

image.load_jpg_from_buffer(body)

var texture -= ImageTexture.new()

texture.create_from_image(image)

promise._success(texture)

Promise

● Example: HTTP request (using promises)func _ready():

var kitten_promise: Promise = kitten_request(800, 600)

kitten_promise.connect("on_success", self, "on_get_kitten")

func on_get_kitten(kitten: Texture):

$KittenSprite.texture = kitten

Promise

Promise in functional programming

● Promise is also a monad!● Monad can also be seen as encapsulation inside a context● In an Array, the context is the multiplicity of values● In Promise, the context is the “promise” of a future resolution● To put in the context is usually called “Lift”

Promise as a functor

● The “mapping” in promise means applying the function to the final resolved value– Like the kitten picture

● It’s possible to define Promise.map() which applies the function only when it resolves (that is, when the async operation completes)

● Remember that map() returns the value inside the context, so Promise.map() also returns a Promise

● In turn, it’s possible to apply other pure operations to a Promise before it is fullfilled

Promise map

● Examplevar users: Promise = request_users() # Promise of array of users

var active_users: Promise = users.map(funcref(self, "filter_active_users"))

active_users.connect("success", self, "print_users")

func filter_active_users(users: Array) -> Array:

return filter(users, funcref(self, "is_active"))

func print_users(users):

print(users)

Monad as side effect isolation

● Like Promise encapsulates an asynchronous operation, Monads can also encapsulate side effect

● It’s possible to operate with the values inside the lifted context (i.e. inside the Promise) without depending on its effects

● Example: operating in a text input before the user types anything– The function needs only to get the text as argument, not wait for the side effect

● Keeping side effects and shared state only in a small and specific part of the code

Learn a functional language

● At first it may be hard to assimilate the concepts● But the patterns can be applied in other languages and be used to

increase code quality● Examples of functional languages:

– Elm, Elixir, Haskell, Clojure, Scala, F#

Questions?

george@gmarqu.es

github.com/vnen

twitter.com/vnen

Thank you!george@gmarqu.es

github.com/vnen

twitter.com/vnen

Recommended