crystal-lang / crystal

The Crystal Programming Language
https://crystal-lang.org
Apache License 2.0
19.47k stars 1.62k forks source link

First-class functions #12723

Open dmgr opened 2 years ago

dmgr commented 2 years ago

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)
asterite commented 2 years ago

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

dmgr commented 2 years ago

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.

oprypin commented 2 years ago

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

dmgr commented 2 years ago

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.

HertzDevil commented 2 years ago

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.

asterite commented 2 years ago

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.)

dmgr commented 2 years ago

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.

asterite commented 2 years ago

That's not the case in Haskell nor Elm.

What language are you thinking about?

dmgr commented 2 years ago

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

asterite commented 2 years ago

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

straight-shoota commented 2 years ago

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?

dmgr commented 2 years ago

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) }
asterite commented 2 years ago

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

HertzDevil commented 2 years ago

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.

beta-ziliani commented 2 years ago

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):

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!
Sija commented 2 years ago

I'd suggest reviving #9197

Blacksmoke16 commented 1 year ago

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.

straight-shoota commented 1 year ago

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).

Blacksmoke16 commented 1 year ago

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?

z64 commented 1 year ago

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.