There's plenty of info online about writing Ruby C Extensions, but why would you want to do so? C is extremely fast, especially when it comes to math. Ruby: not so fast at math. Of course, developer happiness is at the heart of Ruby rather than speed. So then, aside from the perhaps "superficial" reasons of improved speed, one might want to write a C extension to interact with a library already written in C.
In this article, we're going to look at how to write a Ruby C extension that lets the user interact with CLIPS, a programming language used to create Rules Engines and Expert Systems. We'll also discuss some of the things that make Ruby great, including why I think that Ruby is like a framework for the C programming language.
mkmf
We'll use
mkmf
to create a Makefile for us that'll compile our Ruby C
Extension alongside CLIPS. First, we'll download CLIPS into the current
directory, extract it, and delete the makefiles that come
with it:
$ wget https://sourceforge.net/projects/clipsrules/files/CLIPS/6.40/clips_core_source_640.tar.gz # ... truncated output ... HTTP request sent, awaiting response... 302 Found Location: https://versaweb.dl.sourceforge.net/project/clipsrules/CLIPS/6.40/clips_core_source_640.tar.gz [following] --2022-12-11 16:32:46-- https://versaweb.dl.sourceforge.net/project/clipsrules/CLIPS/6.40/clips_core_source_640.tar.gz Resolving versaweb.dl.sourceforge.net (versaweb.dl.sourceforge.net)... 162.251.232.173 Connecting to versaweb.dl.sourceforge.net (versaweb.dl.sourceforge.net)|162.251.232.173|:443... connected. HTTP request sent, awaiting response... 200 OK Length: 1082012 (1.0M) [application/x-gzip] Saving to: ‘clips_core_source_640.tar.gz’ clips_core_source_640.ta 100%[===============================>] 1.03M 2.97MB/s in 0.3s 2022-12-11 16:32:51 (2.97 MB/s) - ‘clips_core_source_640.tar.gz’ saved [1082012/1082012] $ tar --strip-components=2 -xvf clips_core_source_640.tar.gz # ... truncated output ... $ rm makefile*
Now create a file named extconf.rb that looks like this:
require 'mkmf' create_makefile('clipsruby')
We'll now create a clipsruby.c file that looks like this:
#include "clips.h" #include "ruby.h" void Init_clipsruby(void) { VALUE rbCLIPS = rb_define_module("CLIPS"); }
Now run ruby extconf.rb. This creates a
Makefile. Easy so far. Run make and watch the
extension compile. Once it's done, fire up irb to make sure
we can require_relative our new extension:
$ irb
irb(main):001:0> require_relative('./clipsruby')
=> true
irb(main):002:0> CLIPS
=> CLIPS
Sweet. We can now bring in our C extension, and our Ruby environment
knows about the CLIPS module we defined in our
Init_clipsruby function by calling
rb_define_module.
Let's interact with CLIPS via Ruby. For starters, we'll implement
CreateEnvironment from CLIPS. This will create a new CLIPS
environment that we can assert Facts into, define Rules in, and run.
We'll write some C code so that we can run
CLIPS.create_environment. Normally in C world,
CreateEnvironment would return a pointer to a C struct
Environment. In Ruby, you take C structs and "wrap" them.
This wrapping provides the ability to specify "setup" and "clean up"
functionality that must run when Ruby's Garbage Collector frees up
unused memory. This is what I might consider the defining characteristic
of Ruby: it provides safety around underlying memory management that
would normally be left to the developer to take care of.
Lets define our "wrapping" code for the Environment struct
provided by the CLIPS C library:
#include "clips.h" #include "ruby.h" void environment_free(void *data) { DestroyEnvironment((Environment*) data); } size_t environment_size(const void *data) { return MemUsed((Environment*) data); } static const rb_data_type_t Environment_type = { .function = { .dfree = environment_free, .dsize = environment_size }, .flags = RUBY_TYPED_FREE_IMMEDIATELY }; VALUE environment_alloc(VALUE self) { return TypedData_Wrap_Struct(self, &Environment_type, CreateEnvironment()); } void Init_clipsruby(void) { VALUE rbCLIPS = rb_define_module("CLIPS"); VALUE rbEnvironment = rb_define_class_under(rbCLIPS, "Environment", rb_cObject); rb_define_alloc_func(rbEnvironment, environment_alloc); }
The first thing we will note is our usage of
rb_define_class_under to define
CLIPS::Environment as a class under the CLIPS
module. We tell it to inherit from Ruby's Object class,
referred to by rb_cObject.
Next, we define an "allocation" function called
environment_alloc. When we create our CLIPS environment
with CreateEnvironment, CLIPS allocates memory. Remember
that Ruby is awesome at wrapping C structs and representing them as Ruby
Objects? That's because it provides lifecycle "hooks" that we can use to
do something during the instantiation and garbage collection of an
Object. In this way, Ruby is like a framework for the C programming
language.
You'll note that we pass the address of Environment_type as
the second argument to this function. This struct provides a way for us
to "hook into" the lifecycle of an Object in Ruby. In it, we define our
memory freeing functionality, as well as a way to check the size of the
struct we are wrapping. .flags specifies something that
gives us a slight performance boost if we do not
unlock
the GVL (which we won't in this article). There are
other
things we can specify in this struct, but in this particular case,
they're not needed.
We define our environment_free and
environment_size functions above as wrappers around the
CLIPS library functions DestroyEnvironment and
MemUsed respectively.
So far, the code we've written will only allow us to instantiate a
CLIPS::Environment object using
CLIPS::Environment.new in our Ruby code. We haven't yet
defined our static CLIPS.create_environment method, so
let's do that now in clipsruby.c:
#include "clips.h" #include "ruby.h" void environment_free(void *data) { DestroyEnvironment((Environment*) data); } size_t environment_size(const void *data) { return MemUsed((Environment*) data); } static const rb_data_type_t Environment_type = { .function = { .dfree = environment_free, .dsize = environment_size }, .flags = RUBY_TYPED_FREE_IMMEDIATELY }; VALUE environment_alloc(VALUE self) { return TypedData_Wrap_Struct(self, &Environment_type, CreateEnvironment()); } static VALUE create_environment(VALUE self) { return environment_alloc(rb_const_get(self, rb_intern("Environment"))); } void Init_clipsruby(void) { VALUE rbCLIPS = rb_define_module("CLIPS"); rb_define_module_function(rbCLIPS, "create_environment", create_environment, 0); VALUE rbEnvironment = rb_define_class_under(rbCLIPS, "Environment", rb_cObject); rb_define_alloc_func(rbEnvironment, environment_alloc); }
rb_intern lets us pass a C string and returns an
ID.
This is an internal Ruby thing, and it's used to keep track of Ruby
"identifiers." If you're familiar with Ruby's concept of
Symbols,
think of IDs as the C integer corresponding to the
Symbol in Ruby. rb_const_get takes a
VALUE which corresponds to a Ruby Class or Module. It also
takes an ID that exists within the VALUE we
passed as the first argument. The argument passed in to our C function
create_environment will be the Class, Module, Object, etc.
that the method was invoked on. In our case, we'll be doing
CLIPS.create_environment, so that'll be self
in our function. Thus, we pass the Ruby constant
CLIPS::Environment to environment_alloc in our
create_environment C function. It follows that, when called
from Ruby, this function will return an instance of
CLIPS::Enviornment. Beautiful. Finally, we use
rb_define_module_function to define a function in our
CLIPS module named create_environment which
calls our create_environment C function and passes 0
arguments.
For easier testing purposes, let's create a main.rb file
that we'll use to quickly test our extension:
require_relative("./clipsruby") p CLIPS::Environment.new p CLIPS.create_environment
Running make will detect changes to the
clipsruby.c which is nice. We won't have to re-compile
CLIPS each time we make changes to our extension! We can test things out
by doing ruby main.rb. We should see output that looks like
this:
$ ruby main.rb #<CLIPS::Environment:0x00007f5cab9f4070> #<CLIPS::Environment:0x00007fa4739b7688>
We'll define two methods that we'll use to wrap CLIPS's
AssertString C function. One will be a "class method" for
the CLIPS::Environment class, and the other will be an
"instance method" for instances of the
CLIPS::Environment class. We'll return a new Ruby Object
from these methods of the class CLIPS::Environment::Fact
that will wrap Fact structs defined in the CLIPS C library.
Update your clipsruby.c file's Init_clipsruby
function so that it looks like this:
void Init_clipsruby(void) { VALUE rbCLIPS = rb_define_module("CLIPS"); rb_define_module_function(rbCLIPS, "create_environment", create_environment, 0); VALUE rbEnvironment = rb_define_class_under(rbCLIPS, "Environment", rb_cObject); rb_define_alloc_func(rbEnvironment, environment_alloc); rb_define_singleton_method(rbEnvironment, "assert_string", clips_environment_static_assert_string, 2); rb_define_method(rbEnvironment, "assert_string", clips_environment_assert_string, 1); VALUE rbFact = rb_define_class_under(rbEnvironment, "Fact", rb_cObject); }
We use rb_define_singleton_method and
rb_define_method to define a class method and instance
method on CLIPS::Environment respectively. The first method
will take two arguments: the first will be an instance of
CLIPS::Environment, the second will be a
String holding a Fact. It'll look something like
"(foo bar)". The second method is an instance method. This
means we can call it on an instance of CLIPS::Environment.
Since we call it on an instance, we already have the Environment in
which to assert our Fact, so we only need 1 argument for the
String.
We also use rb_define_class_under to define our
Fact class.
Alright, let's write our clips_environment_assert_string
and clips_environment_static_assert_string functions. Add
these lines above your Init_clipsruby function in
clipsruby.c:
static VALUE clips_environment_assert_string(VALUE self, VALUE string) { Environment *env; TypedData_Get_Struct(self, Environment, &Environment_type, env); Fact *fact = AssertString(env, StringValueCStr(string)); VALUE rb_fact = TypedData_Wrap_Struct(rb_const_get(CLASS_OF(self), rb_intern("Fact")), &Fact_type, fact); rb_iv_set(rb_fact, "@environment", self); return rb_fact; } static VALUE clips_environment_static_assert_string(VALUE self, VALUE rbEnvironment, VALUE string) { return clips_environment_assert_string(rbEnvironment, string); }
We'll look at clips_environment_assert_string first. The
first argument is the instance of the CLIPS::Environment we
are calling this on. We'll "unwrap" the Ruby object to get the
Environment struct inside using
TypedData_Get_Struct. In order to "unwrap" the object, we
must pass:
Environment
We then use the unwrapped Environment to assert the string
passed as the second argument to
clips_environment_assert_string. We use
StringValueCStr to convert the Ruby string to a C string.
Just like in environment_alloc, we'll use
TypedData_Wrap_Struct to create our newly asserted
Fact struct. We want to use the class we created in our
Init_clipsruby function called
CLIPS::Environment::Fact, so we make use of
rb_const_get again. This time, though, we must use
CLASS_OF to convert the variable self into a
Ruby class. Right now, self is the instance of a
CLIPS::Environment, and rb_const_get expects a
class.
You'll note we assume a struct called Fact_type exists.
We'll create this in our next step. For now, let's finish reading
through this function.
rb_iv_set provides a way for us to set an instance variable
on our newly wrapped CLIPS::Environment::Fact instance.
We'll store the environment in which the fact is asserted as the
instance variable @environment on the
CLIPS::Environment::Fact object.
A somewhat neat pattern emerges when we want to create a static method
on the CLIPS::Environment class. We can call the
clips_environment_assert_string function we just wrote in
clips_environment_static_assert_string. This is the same
pattern that can be used in Ruby for writing static wrapping class
functions that take an instance of the class as its first argument
sort-of like this:
class Foo def self.bar(foo) foo.bar end def bar p "Bar!" end end Foo.bar(Foo.new)
Alright, let's implement Fact_type. Write this in your
clipsruby.c above the previous two functions.
size_t fact_size(const void *data) { return sizeof(Fact); } static const rb_data_type_t Fact_type = { .function = { .dsize = fact_size }, .flags = RUBY_TYPED_FREE_IMMEDIATELY };
This struct is much simpler than our Environment_type
struct. That's because the memory used to store the Fact is managed by
CLIPS, so we don't need to specify any kind of clean up functionality.
We define fact_size here to get the size of the
Fact struct, but this may not be the best approach. At any
rate,
we
don't necessarily need to specify .dsize, so if
you don't like this approach, just remove the .function
block altogether. From the official Ruby website:
You can pass 0 as dsize if it is hard to implement such a function. But it is still recommended to avoid 0.
Let's test this out. Update your main.rb so that it looks
like this:
require_relative("./clipsruby") env = CLIPS::Environment.new p env.assert_string("(foo bar)") p CLIPS::Environment.assert_string(env, "(baz bat)")
Run this with ruby main.rb and your console output should
look something like this:
$ ruby main.rb #<CLIPS::Environment::Fact:0x00007f75aea4f0f8 @environment=#<CLIPS::Environment:0x00007f75aea4f2d8>> #<CLIPS::Environment::Fact:0x00007f75aea4e9a0 @environment=#<CLIPS::Environment:0x00007f75aea4f2d8>>
We can assert facts, but how do we see which facts are in our
environment? We'll wrap the Facts function. This acts like
(facts) in CLIPS, and it will write out to STDOUT all of
the facts in our environment. Update the Init_clipsruby
function in your clipsruby.c file like so:
void Init_clipsruby(void) { VALUE rbCLIPS = rb_define_module("CLIPS"); rb_define_module_function(rbCLIPS, "create_environment", create_environment, 0); VALUE rbEnvironment = rb_define_class_under(rbCLIPS, "Environment", rb_cObject); rb_define_alloc_func(rbEnvironment, environment_alloc); rb_define_singleton_method(rbEnvironment, "assert_string", clips_environment_static_assert_string, 2); rb_define_method(rbEnvironment, "assert_string", clips_environment_assert_string, 1); rb_define_singleton_method(rbEnvironment, "facts", clips_environment_static_facts, 1); rb_define_method(rbEnvironment, "facts", clips_environment_facts, 0); VALUE rbFact = rb_define_class_under(rbEnvironment, "Fact", rb_cObject); }
Now we need to implement clips_environment_static_facts and
clips_environment_facts above our
Init_clipsruby function:
static VALUE clips_environment_facts(VALUE self) { Environment *env; TypedData_Get_Struct(self, Environment, &Environment_type, env); Facts(env, "stdout", NULL, -1, -1, -1); return self; } static VALUE clips_environment_static_facts(VALUE self, VALUE rbEnvironment) { return clips_environment_facts(rbEnvironment); }
Update your main.rb file so that it looks like this:
require_relative("./clipsruby") env = CLIPS::Environment.new env.assert_string("(foo bar)") CLIPS::Environment.assert_string(env, "(baz bat)") env.facts CLIPS::Environment.facts(env)
Now running ruby main.rb should look something like this:
$ ruby main.rb f-1 (foo bar) f-2 (baz bat) For a total of 2 facts. f-1 (foo bar) f-2 (baz bat) For a total of 2 facts.
Nice, looks like our class and instance methods work as expected. Note
that we do not have to use Ruby's p method to print out our
facts. We've implemented our facts method to print to
stdout from CLIPS.
CLIPS::Environment::Fact ObjectsLet's explore some ways in which we can shuttle the value of Fact slots between Ruby and CLIPS. Since both languages are written in C, we can use the C space to translate the underlying C values into objects that their respective languages understand.
We'll start with something easy: let's write functionality to return the
name of a Fact's Deftemplate. In CLIPS, a Deftemplate is named by the
first word in a Fact. Something like (foo a b c) would have
foo as the name of its Deftemplate. Update your
Init_clipsruby function in your clipsruby.c
file:
void Init_clipsruby(void) { VALUE rbCLIPS = rb_define_module("CLIPS"); rb_define_module_function(rbCLIPS, "create_environment", create_environment, 0); VALUE rbEnvironment = rb_define_class_under(rbCLIPS, "Environment", rb_cObject); rb_define_alloc_func(rbEnvironment, environment_alloc); rb_define_singleton_method(rbEnvironment, "assert_string", clips_environment_static_assert_string, 2); rb_define_method(rbEnvironment, "assert_string", clips_environment_assert_string, 1); rb_define_singleton_method(rbEnvironment, "facts", clips_environment_static_facts, 1); rb_define_method(rbEnvironment, "facts", clips_environment_facts, 0); VALUE rbFact = rb_define_class_under(rbEnvironment, "Fact", rb_cObject); rb_define_singleton_method(rbFact, "deftemplate_name", clips_environment_fact_static_deftemplate_name, 1); rb_define_method(rbFact, "deftemplate_name", clips_environment_fact_deftemplate_name, 0); }
We're using a naming scheme here for our function names;
clips_environment_fact_static_deftemplate_name is a
mouthful, but it clearly describes "we are making a static method on the
CLIPS::Environment::Fact class called
deftemplate_name." This clarity will only help us in the
future.
Speaking of long-winded function names: let's implement
clips_environment_fact_static_deftemplate_name and
clips_environment_fact_deftemplate_name above our
Init_clipsruby function:
static VALUE clips_environment_deftemplate_name(VALUE self) { Fact *fact; TypedData_Get_Struct(self, Fact, &Fact_type, fact); return ID2SYM(rb_intern(DeftemplateName(FactDeftemplate(fact)))); } static VALUE clips_environment_static_deftemplate_name(VALUE self, VALUE rbFact) { return clips_environment_deftemplate_name(rbFact); }
FactDeftemplate and DeftemplateName are
provided by the CLIPS C library. FactDeftemplate takes an
argument Fact* and returns a Deftemplate*. In
CLIPS, Deftemplates are kind-of like classes in Object Oriented
Programming. You can define named slots, and slot order is not dependent
unlike non-deftemplate facts asserted. For example, (foo (bar
"Bar!") (baz "Baz!")) and (foo (baz "Baz!") (bar
"Bar!")) are the same, while (foo a b c) is
different than (foo b c a). In both cases, the
Deftemplate's name would be foo.
We pass the C string "foo" returned from
DeftemplateName to Ruby's rb_intern which
converts it into a Ruby ID. We then pass this Ruby ID to
ID2SYM, a Ruby C function that converts IDs to Ruby
symbols.
Let's update our main.rb file to look like this:
require_relative("./clipsruby") env = CLIPS::Environment.new fact = env.assert_string("(foo bar)") p fact.deftemplate_name p CLIPS::Environment::Fact.deftemplate_name(fact)
Now you can do make; ruby main.rb and look at the output:
$ make; ruby main.rb compiling clipsruby.c linking shared-object clipsruby.so :foo :foo
This has been the first part of a small series in writing a Ruby C Extension, specifically for leveraging CLIPS. I'm excited to continue on with a few more of these, detailing some awesome things including translating between Ruby and CLIPS values seamlessly. Stay tuned!
- ryjo