crystal icon indicating copy to clipboard operation
crystal copied to clipboard

First-class functions

Open dmgr opened this issue 2 years ago • 20 comments

I think it would be interesting to see a Ruby inspired language with methods as first-class functions support which let you write functional oriented programming a breeze. Of course it will affect syntax as you will be required to use parenthesis to call a function, but I tend to use them anyway. This is a feature I miss in Ruby.

That feature could be introduced by a directive on the beginning of a .rb file, similar how it is done with # frozen_string_literal: true. So a directive like # first_class_functions: true can be used.

TL;DR

I would like to see that syntax possible:

operation = Math.sin
2.then(operation)

instead of the current approach:

operation = Math.method(:sin)
2.then(&operation)

dmgr avatar Nov 06 '22 15:11 dmgr

Hi! Could you provide some example code of what you mean? Functions are first class citizens in Crystal. That's Proc.

asterite avatar Nov 06 '22 16:11 asterite

I would like to see that syntax possible:

operation = Math.sin
2.then(operation)

instead of the current approach:

operation = Math.method(:sin)
2.then(&operation)

which will encourage community to write a neat functional code.

dmgr avatar Nov 06 '22 17:11 dmgr

You are describing Ruby but this repository is about Crystal programming language. Are you sure you're in the right place?

oprypin avatar Nov 06 '22 18:11 oprypin

I would like to see Crystal as a better Ruby. As Crystal is Ruby inspired language, it can deviate from it. So I would like Crystal has first-class functions also for methods. If Crystal would be designed with it from the beginning, it would be a very sexy language in my opinion.

dmgr avatar Nov 06 '22 18:11 dmgr

You must provide the parameter types yourself:

struct Float64
  def then(fn : Float64 -> T) : T forall T
    fn.call(self)
  end
end

Math::PI.then(->Math.sin(Float64)) # => 1.2246467991473532e-16

->Math.sin alone would have produced some kind of method object rather than a Proc due to the possibility of overloading, in which case the benefits of FP quickly vanish. (It may look like this particular example could be improved by a new form of autocasting, but it isn't as simple as that.) It is actually harder than in Ruby where overloads do not exist outside RBS.

Also the standard library does not have a commitment to support FP beyond e.g. the higher-order methods in Enumerable. There are probably shards out there that do this for you.

HertzDevil avatar Nov 06 '22 19:11 HertzDevil

Also, and this is just my opinion, I think it's really bad design if you write Math.sin and that gives you a function instead of telling you "Hey, you are missing arguments." It's one of the gripes I have with many functional programming languages. It leads to really bad error messages.

I prefer the explicitness of Ruby (and Crystal.)

asterite avatar Nov 06 '22 19:11 asterite

It is not "explicitness of Ruby (and Crystal)".

You can rather say otherwise: it is the explicitness of functional languages that they require you to explicitly use parenthesis if you want to call the function.

dmgr avatar Nov 06 '22 19:11 dmgr

That's not the case in Haskell nor Elm.

What language are you thinking about?

asterite avatar Nov 06 '22 20:11 asterite

Julia is the language I am thinking about: https://rosettacode.org/wiki/First-class_functions#Julia

dmgr avatar Nov 06 '22 20:11 dmgr

What if Math.sin doesn't take arguments? Would that be a call to that method, or it would return the function that you can later call using parentheses?

I think your proposal in the end requires always using parentheses for calls, and never using parentheses when you don't want to call something. That would be a huge breaking change.

I don't think this change will ever happen.

See also: https://github.com/crystal-lang/crystal/issues/8591

asterite avatar Nov 06 '22 21:11 asterite

The concrete proposal to let the method name without parentheses refer to the method itself would be too much of a breaking change for Crystal and Ruby likewise.

But I suppose it wouldn't hurt to talk about alternative ideas for facilitating a more functional programming style in Crystal?

straight-shoota avatar Nov 06 '22 22:11 straight-shoota

Would it be possible to have a way to turn such a feature on/off somehow, for example using a pragma:

# functional_programming: true

or a block directive?:

with :functional_programming do
  operation = Math.sin
  2.then(operation) # it will be 2.then(&operation) in this case as #then expects block
end

or a refining kind of expression:

using FunctionalProgramming

operation = Math.sin
2.then(operation) # it will be 2.then(&operation) in this case as #then expects block

This way all the current will work as is and when you want to use a more functional programming style (for example in a data science projects) you will have a way to turn it on.

It will lead to more changes in that scopes, for example to let you call lambdas using parentheses only:

using FunctionalProgramming

multiply = ->(a) { ->(b) { a * b } } # or even more simplified way: multiply = ->(a) ->(b) { a * b }
multiply(2)(3) # => 6
# currently this works:
multiply[2][3] # => 6
multiply.(2).(3) # => 6
multiply.call(2).call(3) # => 6

So referring back to https://rosettacode.org/wiki/First-class_functions#Ruby

The code at the end could looks like this in Crystal:

using FunctionalProgramming # turning "always use parentheses for calls" mode on

cube     = ->(x) { x**3 }
croot    = ->(x) { x**(1.0/3.0) }
# currying function:
compose  = ->(f,g) ->(x) { f(g(x)) } # or ->(f,g)->(x) { f g x }
funclist = [Math.sin,  Math.cos,  cube]
invlist  = [Math.asin, Math.acos, croot]

puts funclist.zip(invlist).map { |f, invf| compose(f, invf)(0.5) }

dmgr avatar Nov 06 '22 22:11 dmgr

How much experience do you have with Crystal? Are you using it in production? Any hobby project you can share?

asterite avatar Nov 06 '22 23:11 asterite

The snippet for Ruby is itself an example that you don't need first-class functions to be able to write FP-oriented code, because Ruby too only has methods, not first-class functions. This is an example of mimicking it in Crystal (with full currying):

macro def_functor(name, &block)
  {% for param, i in block.args %}
    {% type_params = (0...i).map { |j| "T#{j}".id } %}

    record Func_{{ name.id }}_{{ i }}{% if i > 0 %}({{ type_params.splat }}){% for t, j in type_params %}, {{ block.args[j] }} : {{ t }}{% end %}{% end %} do
      def []({{ param }})
        {% if i < block.args.size - 1 %}
          Func_{{ name.id }}_{{ i + 1 }}.new({{ block.args[0..i].splat }})
        {% else %}
          {{ block.body }}
        {% end %}
      end

      def [](arg, *rest : _)
        self[arg][*rest]
      end
    end
  {% end %}

  def self.{{ name.id }}
    Func_{{ name.id }}_0.new
  end
end

module Foo
  def_functor(cube) { |x| x ** 3 }
  def_functor(croot) { |x| x ** (1.0 / 3) }
  def_functor(compose1) { |f, g, x0| f[g[x0]] }

  module Math
    def_functor(sin) { |x| ::Math.sin(x) }
    def_functor(cos) { |x| ::Math.cos(x) }
    def_functor(asin) { |x| ::Math.asin(x) }
    def_functor(acos) { |x| ::Math.acos(x) }
  end

  funclist = [Math.sin, Math.cos, cube]
  invlist = [Math.asin, Math.acos, croot]
  puts funclist.zip(invlist).map { |f, invf| compose1[f, invf][0.5] }.join('\n')
end

Anything larger than that amounts to reimplementing the entire standard library.

HertzDevil avatar Nov 07 '22 01:11 HertzDevil

The main ingredients are there. This is the working version of the code above:

cube     = ->(x : Float64) { x**3 }
croot    = ->(x : Float64) { x**(1.0/3.0) }

compose  = ->(f : Float64 -> Float64, g : Float64 -> Float64) { ->(x : Float64) { f.call(g.call(x)) } }
funclist = [->Math.sin(Float64),  ->Math.cos(Float64),  cube]
invlist  = [->Math.asin(Float64), ->Math.acos(Float64), croot]

puts funclist.zip(invlist).map { |f, invf| compose.call(f, invf).call(0.5) } # => [0.5, 0.4999999999999999, 0.5000000000000001]

There are a couple of things worth pointing out (I take Scala as a reference):

  • Specifying the type of the overload is reasonable (->Math.sin(Float64)). I don't think there's much debate here.
  • Calling specifically the proc (g.call(x)) is not terrible. But maybe we could look into having a .() class/instance method to handle such things.
  • The real deal is with having to type all of the arguments, and the corresponding lack of generics in procs.

If we were capable of substituting generic types in procs, the code would be definitively nice. This said, note that cube and croot still need the type. But compose's types could in theory be guessed:¹

cube     = ->(x : Float64) { x**3 }
croot    = ->(x : Float64) { x**(1.0/3.0) }

compose  = ->(f, g) { ->(x) { f.call(g.call(x)) } }
funclist = [->Math.sin(Float64),  ->Math.cos(Float64),  cube] # We need to know what `cube` is!
invlist  = [->Math.asin(Float64), ->Math.acos(Float64), croot]

# funclist and invlist have type Array(Proc(Float64, Float64)), so `f` and `invf` can be inferred, and so `compose`.
puts funclist.zip(invlist).map { |f, invf| compose.call(f, invf).call(0.5) } # => [0.5, 0.4999999999999999, 0.5000000000000001]

¹ Don't quote me on this!

beta-ziliani avatar Nov 07 '22 17:11 beta-ziliani

I'd suggest reviving #9197

Sija avatar Nov 07 '22 18:11 Sija

The concrete proposal to let the method name without parentheses refer to the method itself would be too much of a breaking change for Crystal and Ruby likewise.

But I suppose it wouldn't hurt to talk about alternative ideas for facilitating a more functional programming style in Crystal?

Was working on porting some PHP code and noticed they have https://www.php.net/manual/en/functions.first_class_callable_syntax.php now in 8.1 which allows you do do $someObj->someMethod(...) to create an anonymous function from a callable similar to Crystal's ->some_obj.some_method.

I'm not saying that's something we should or should not have, but I did want to bring up some of the features it allows. Mainly that it allows retaining the class/method name used to create the function. In crystal when you use ->some_method you lose all that information.

EDIT: Accessible via reflection, not directly on the closure.

I also recently learned the proc exposes its closured data (the object), but in a seemingly not robust/unsafe way? Given I would have expected the ID/object ID of each instance to be the same since the proc would closure a reference to the original object?

class Test
  @id : Int32 = 0

  def on_message : Nil
    puts "foo"
  end
end

t = Test.new

t # => #<Test:0x7efd374acea0 @id=0>

proc = ->t.on_message

proc.closure_data.as Test # => #<Test:0x7efd374abff0 @id=32509>

It might be worth either fixing or documenting this as it could be a pretty slick way to retain access to information from the original object.

Blacksmoke16 avatar Feb 25 '23 16:02 Blacksmoke16

There seems to be some misconception about closure_data. It's not a direct pointer to some closured value as you seem to expect. That closure_data.as(Test) cast is just nonsense.

closure_data is to be treated as an opaque pointer to the closure context. You can't read anything from it directly (I guess technically there would be some way to do that, but it's quite a bit complex).

straight-shoota avatar Feb 25 '23 17:02 straight-shoota

I was able to get it to work with some help on Discord:

record MyMessage
 
class Test
  property id = 10
 
  def on_message(message : MyMessage) : Nil
    puts "foo"
  end
 
  def test : Nil
    @id += 20
  end
end
 
t = Test.new
 
pp t # => #<Test:0x7f862979dea0 @id=10>
 
proc = ->t.on_message(MyMessage)

proc_t = proc.closure_data.as(Test*).value
proc_t.test
proc_t.id += 10

pp t # => #<Test:0x7f862979dea0 @id=40>

However based on what you say, it seems like it's just a coincidence that it works like this? If so, should this method even be public?

Blacksmoke16 avatar Feb 26 '23 00:02 Blacksmoke16

should this method even be public?

closure_data can be used to make Crystal closures integrate with C APIs. It is very common for C APIs that accept a function pointer to also accept a void* data pointer, that is passed back to you when the function is called. Thus you could reconstruct the closure proc with the corresponding Proc#new(pointer, closure_data) method.

z64 avatar Feb 26 '23 10:02 z64