Skip to content

Code guidelines and workarounds

Dr. Philipp Schulz edited this page Apr 11, 2023 · 16 revisions

Anyolite is designed to take work out of binding Crystal functions to Ruby. However, the way Ruby and Crystal work are more different than their language similarities might show. Therefore, it is necessary to provide certain information to Anyolite in order for it to generate correct bindings which are compliant with the rest of the code.

Anyolite tries to get as many information as possible and provides annotations and options, where the automatic information collection fails. Nonetheless, certain coding guidelines can help in minimizing the amount of annotations needed.

General code guidelines

Unsupported types

Anyolite does not support every Crystal time as a return value or argument value for bindings. Therefore, it is necessary to avoid using the following types:

  • Symbol (will be casted to String if not possible otherwise)
  • Slice
  • Proc
  • Class and Module

Other types are possible to use, but do not have their full functionality:

  • Pointer (passed using special containers, but not advised)
  • Array (passed as copy)
  • Hash (passed as copy)
  • String (passed as copy)
  • Char (will be casted to String and back)
  • Regex (currently only passable in mruby, named Regexp there)

Some specific types like Time are also not directly supported (yet), but could technically be wrapped.

Safe types with full functionality are:

  • Int and subtypes
  • Float and subtypes
  • Bool
  • Nil
  • User-defined instances of wrapped structs and enums (*)
  • User-defined instances of wrapped classes
  • Unions of supported types

(*) Note that structs can only be modified directly if they are wrapped directly, but not if they are attributes of other classes or structs. In the latter case, the struct attribute needs to be copied, modified and then reassigned in order to be actually modified.

Function arguments

Anyolite can cast the return type of a Crystal function to Ruby at runtime, so it does not need to be specified. However, argument types given to Crystal need full specification.

For example, the function

def print(something)
  # ...
end

could not be wrapped to Anyolite in this state. Providing all possible arguments helps:

def print(something : String | Int | Float)
  # ...
end

Default values are possible, but still need full type specification:

def print(something : String | Int | Float = "Default string")
  # ...
end

Another limitation of Anyolite is that default arguments (and also argument types in more nested types) need their full module/class path written out, even if the definition is in the same context:

class Dummy
  SOME_CONSTANT = "Hello World"

  def greeter(text : String = SOME_CONSTANT)
    # Anyolite will trigger an error here!
  end

  def greeter(text : String = Dummy::SOME_CONSTANT)
    # This is okay.
  end
end

This limitation is due to the fact that Anyolite uses macros for most of its methods, so the definition context is not known at the point where the macro function is called. For the argument types, the context and therefore full path can be determined in many cases, but for default arguments, this is not possible anymore (imagine a default argument like some_func(other_func(2 * weird_constant + 3))).

It is possible that future versions of Anyolite will do a better job of determining the full path of types and default arguments, but for now, the safest way is to specify the full path, if in doubt.

Generic types

Generic types are possible to bind with Anyolite, but not out of the box. You need to specify each single specified generic type at compiletime. For example:

module MyModule
  struct Vector(T)
    @x : T
    @y : T
    def initialize(@x : T, @y : T)
    end
  end
end

Then, there are two thing to do: Anyolite needs to know that Vector is a generic struct with the generic type T. Secondly, Anyolite needs to be informed of each used generic type. If we want to have vectors of Float32, Int32 and String, the following code will give Anyolite all needed information:

module MyModule
  @[Anyolite::SpecifyGenericTypes([T])]
  struct Vector(T)
    @x : T
    @y : T
    def initialize(@x : T, @y : T)
    end
  end

  alias VectorFloat32 = Vector(Float32)
  alias VectorInt32 = Vector(Int32)
  alias VectorString = Vector(String)
end

The obvious tradeoff here is that an alias needs to be created for each specified generic type, but these can be added to existing modules without problems. Each specified generic type will then represent a single class in Ruby with all methods.

Workarounds

Sometimes it is not possible to follow the guidelines (for example if you want to bind somebody else's library with Anyolite), so some additional work needs to be done in order to provide all necessary information to Anyolite. Some specific cases are covered in the following sections.

Argument specification

If you wrap a function, Anyolite will use the function definition if there is no other information provided. If the function definition is not sufficent enough, you need to provide the information manually. This is possible using annotations in two ways. Take the following function definition as an example:

class World
  def generate(width, height, name)
    # Generation routines...
  end
end

To wrap this method, you can either annotate the generate method itself or the World class:

# Instance method annotation
class World
  @[Anyolite::Specialize([width, height, name], [width : UInt32, height : UInt32, name : String])
  def generate(width, height, name)
    # Generation routines...
  end
end

# Class annotation
@[Anyolite::SpecializeInstanceMethod("generate", [width, height, name], [width : UInt32, height : UInt32, name : String])
class World
  def generate(width, height, name)
    # Generation routines...
  end
end

If you are unable to annotate the function directly, the class annotation is a good alternative (at the cost of slightly longer code), which does exactly the same as the method annotation. A analogous annotation is also available for class methods (see the Anyolite documentation for a full list of annotations).

It is also possible to change argument types (like from a union to a single type) or specify default argument using these annotations. The latter scenario is relevant if you want to add full paths to argument types and default arguments, for example.

Non-keyword arguments

Currently, Anyolite does generate keyword methods in most cases (except for function names ending with a symbol, to make operators less complicated). Since these keywords need to be specified explicitly in the Ruby code, this might be unwanted behavior.

Anyolite provides simple annotations to prevent keyword usage:

# Instance method annotation
class World
  @[Anyolite::WrapWithoutKeywords]
  def generate(width : UInt32, height : UInt32, name : String)
    # Generation routines...
  end
end

# Class annotation
@[Anyolite::WrapWithoutKeywordsInstanceMethod("generate")]
class World
  def generate(width : UInt32, height : UInt32, name : String)
    # Generation routines...
  end
end

It is also possible to give an additional integer argument to the annotations, in which case it specifies the number of arguments which are not wrapped as keyword arguments. For example, take the function

@[Anyolite::WrapWithoutKeywords(2)]
def generate(width : UInt32, height : UInt32, name : String = "default")
end

as an example. The annotation will prevent the width and height arguments from being keyword arguments, but still wrap the name argument as a keyword.

If you want to remove keyword methods for a whole class or module, annotate it with Anyolite::NoKeywordArgs.

Finally, it is also possible to add the keyword behavior as default (for operator methods or if you used the Anyolite::NoKeywordArgs annotation on the class or module), using the Anyolite::ForceKeywordArg annotations.

Use keywords only for optional arguments

If you specifically want to follow the guideline of only using keyword arguments for optional arguments, you have two options.

You can either set this property globally (since Anyolite 1.1.0):

ANYOLITE_DEFAULT_OPTIONAL_ARGS_TO_KEYWORD_ARGS = true

Or you can set this behavior only for a specific class or module:

@[Anyolite::DefaultOptionalArgsToKeywordArgs]
class MyClass
end

For now, this will only be a custom flag to avoid breaking backward compatibility with older Anyolite versions. If Anyolite reaches 2.0.0, this will become the default behavior!

Renaming and exclusions

Sometimes, it is necessary to rename a function or remove it altogether from the wrapping routines. The following (totall realistic) example shows how to do this:

@[Anyolite::ExcludeInstanceMethod("crash_the_game")]
@[Anyolite::RenameInstanceMethod("stupid_name", "nice_name")]
class Enemy
  
  def crash_the_game
  end

  @[Anyolite::Exclude]
  def delete_everything
  end

  def stupid_name
  end

  @[Anyolite::Rename("polite_name")]
  def vulgar_name
  end
end

It can also be done with constants (using Anyolite::RenameConstant and Anyolite::ExcludeConstant on the class) and operator functions in the same way.

If you want to rename a class or module, the annotations Anyolite::RenameClass and Anyolite::RenameModule can be used similarly to Anyolite::RenameConstant, while Anyolite::ExcludeConstant works on classes and modules per default.

Dealing with overloaded functions

Another typical Crystal feature is the ability to overload functions with different argument types. This is not directly possible in Ruby, but there are some things you can do to mimic this behavior.

If you wrap multiple overloads of a function, only one of them (depending on their appearance in the code) will be selected (and a warning will show), so you need to specify one single function.

For example, you can do the following:

class Spellbook
  @[Anyolite::Specialize]
  def get_spell(page : UInt32, row : UInt32)
    # ...
  end

  def get_spell(name : String)
    # ...
  end
end

This will exclude the second method (and all others with that name) from wrapping. You can once again also use Anyolite::SpecializeInstanceMethod for the specialization, in which case you need to provide the function name and a list of its arguments as arguments. If the argument list of a function should be empty, just use nil instead of a list of arguments.

In the case of sufficiently similar functions, you can also cheat a bit:

@[Anyolite::SpecializeInstanceMethod("display", [content : Int32], [content : Int32 | String])
class Textboard
  def display(content : Int32)
  end

  def display(content : String)
  end
end

This is completely valid, since both union types are actually valid argument types, and Anyolite is only referring to the method name instead of the actual method. As long as all possibilities of the final annotation argument are covered by functions, overloading can be simulated in that way (otherwise you will most likely encounter an error).

Handling illegal return types

Some functions might return a type not allowed in Anyolite. If you are not able to modify the function accordingly, Anyolite provides the Anyolite::ReturnNil, Anyolite::ReturnNilInstanceMethod and Anyolite::ReturnNilClassMethod annotations (similar to the annotations in the sections above), which will change the return type of the respective function to a simple nil.