Be Newsletter

Volume II, Issue 32; August 12, 1998

Table of Contents

 


BE ENGINEERING INSIGHTS: Fun with Threads, Part 1

By Pavel Cisler

Threads make BeOS responsive. Threads help programmers achieve good perceived performance and snappiness in their application even on slow hardware. Threads also make it really easy to add subtle, hard to track down bugs that only happen on the user's system, never on the programmer's machine.

This article is for C++ programmers. It shows a few typical cases of thread use, pointing out common pitfalls and isolating the low-level thread calls into prefabricated C++ classes that make it easier to avoid some common mistakes. The article also contains some mandatory advanced C++ trickery that we all live for.

Let's start with the simplest kind of thread -- the fire and forget thread. This thread is self-contained. It has a copy of the state it needs for its work, so it doesn't need to obtain any information from other threads, and therefore needs no complex synchronization.

First we need a simple thread base class that we'll subclass in all our examples (we include all the code inside the class definition to save valuable article real estate; in the real world we would split it up):

class ThreadPrimitive {
public:

  ThreadPrimitive(int32 priority = B_LOW_PRIORITY,
    const char *name = 0)
    :  scanThread(-1),
       priority(priority),
       name(name)
    {}

  virtual ~ThreadPrimitive()
    {
      if (scanThread > 0) {
        kill_thread(scanThread);
        ASSERT(!"should not be here");
      }
    }

  void Go()
    {
      scanThread = spawn_thread(&ThreadPrimitive::RunBinder,
        name ? name : "UntitledThread", priority, this);
      resume_thread(scanThread);
    }


  virtual void Run() = 0;

private:

  static status_t RunBinder(void *castToThis)
    {
      // In this call we do the dirty casting work, making
      // the rest of the interfaces fully typed and clean

      ThreadPrimitive *self = (ThreadPrimitive *)castToThis;
      self->Run();

      return B_OK;
    }

protected:
  thread_id scanThread;
  int32 priority;

private:
  const char *name;  // only valid in the constructor and in
                     // the Go call
};

ThreadPrimitive is an abstract base class -- you have to subclass it to make it instantiable. Specifically, you must implement the pure virtual Run() function. Your implementation should incorporate the code that does the actual work for which the thread was spawned. In other words, each ThreadPrimitive subclass performs a specific task. In addition, we expect all subclasses to privatize the constructor and provide a static "perform" function that constructs an object and then calls Go().

Note that the thread has two sides. One side (the constructor and the Go() call) is accessible from the caller side, and one is accessed once the thread is running. These two sides are in two totally different contexts and we need to be aware of that.

For instance, the name member variable is not a copy, but a pointer to the string passed in the constructor. It may not be a pointer to a valid name by the time RunBinder() or Run() gets to run -- it may go out of scope on the spawner side, get deleted, etc. We'll augment this by making it private so that a subclass can't use it in Run() by accident.

Let's look at a PrimitiveThread subclass. The FindAFileThread does a recursive search for a file starting at a specified directory, and then opens the file with its preferred app. All the object needs is the name of the file and the directory it should start at. In order to be self contained, the subclass needs its own copies of these two pieces of data.

class FindAFileThread: private ThreadPrimitive {
public:
  static void Launch(const BEntry *startDir,
    const char *lookForName, int32 priority = B_LOW_PRIORITY,
    const char *name = 0)
    {
      FindAFileThread *thread =
        new FindAFileThread(startDir, lookForName,
          priority, name);

      if (thread->Go() != B_OK)
        // failed to launch, clean up
        delete thread;
    }

private:
  FindAFileThread(const BEntry *startDir,
    const char *lookForName, int32 priority, const char *name)
    :  ThreadPrimitive(priority, name),
       startDir(*startDir),
       lookForName(strdup(lookForName))
    {}

  virtual ~FindAFileThread()
    {
      free (lookForName);
    }

  virtual void Run()
    {
      char buffer[B_FILE_NAME_LENGTH];
      startDir.GetName(buffer);
      printf("looking for %s in directory %s\n",
        lookForName, buffer);
      // ... look for <lookForName> recursively in startDir
      // left out as an exercise for the reader

      delete this;
      // clean up after ourselves when we are done
    }

  // copy of the state our thread needs to Run()
  BEntry startDir;
  char *lookForName;
};

We said the thread needs to be self contained. That also means it needs to clean up after itself once it's done running. You can see that it deletes itself at the end of the Run() call.

To use the object, we call the static Launch() function:

  FindAFileThread::Launch(&someDirRef, "APM.h");

Remember that the constructor must be private. This is to only allow a heap-allocated instance of the thread. A stack-based instance wouldn't work for a couple of reasons. First, the thread deletes itself when it's done. Second, if it was declared as a local instance and the spawning function quit while the object was still running, the local instance would be deleted, killing the thread with it.

The constructor also makes copies of the state the thread uses -- we need a destructor to delete the lookForName copy obtained by strdup(). (Note that in R4 there will be a nice new BString class that we could have used here, allowing us to avoid the explicit destructor).

As you can see, to implement our FindAFileThread we wrote a fairly simple subclass of ThreadPrimitive. But we still needed to subclass and there was still quite a bit of stuff to remember (and mess up), and this is, after all, a very simple example.

If you're adventurous (or are writing a big app with a lot of threading), you could use the following thread class, which utilizes function objects to avoid having to subclass ThreadPrimitive each time. As you may know, function objects are classes that have an operator() -- they know how to call themselves. They usually pack a function pointer and necessary parameters to be used as arguments during a function call. They're actually very useful in threading code. When you're using a thread, you still want to call some code, not right there but asynchronously, in a different context. A function object is about packaging up all the information you need to perform the call later. Function objects are a part of STL; we'll use our own here to serve the purpose of the interface we want to use.

class FunctionObject {
public:
  virtual void operator()() = 0;
  virtual ~FunctionObject() {}
};

This is the FunctionObject base class that defines the
interface our thread will understand.

class FireAndForgetThread: private ThreadPrimitive {
public:
  static void Launch(FunctionObject *functor,
    int32 priority = B_LOW_PRIORITY, const char *name = 0)
    {
      FireAndForgetThread *thread =
        new FireAndForgetThread(functor, priority, name);

      if (thread->Go() != B_OK)
        // failed to launch, clean up
        delete thread;
    }

private:
  FireAndForgetThread(FunctionObject *functor,
    int32 priority, const char *name)
    :  ThreadPrimitive(priority, name),
       functor(functor)  // take over the function
                         // object ownership
    {}

  virtual ~FireAndForgetThread()
    {
      delete functor;
    }

  virtual void Run()
    {
      (*functor)();
      // invoke the function object to get threads work done

      delete this;
      // clean up after ourselves when we are done
    }

  FunctionObject *functor;
    // functor owned by the thread
};

This time there are no task-specific arguments in the Launch() call and in the constructor. The directory and filename parameters that we passed explicitly in the previous example are now packaged up in a function object, along with a target function that's called when the thread runs. The bare FunctionObject doesn't do much. In practice you'll use one of the prefabricated function objects that you have for this purpose, for instance:

template <class Param1, class Param2>
class TwoParamFunctionObject : public FunctionObject {
public:
  TwoParamFunctionObject(void (*callThis)(Param1, Param2),
    Param1 param1, Param2 param2)
    :  function(callThis),
       param1(param1),
       param2(param2)
    {
    }

  virtual void operator()()
    { (function)(param1.Pass(), param2.Pass()); }

private:
  void (*function)(Param1, Param2);
  ParameterBinder<Param1> param1;
  ParameterBinder<Param2> param2;
};

The function object above works with static target functions that take two parameters. ParameterBinder is a little bit of magic that uses template specialization to accommodate different function object parameters differently. Remember, we need to make a copy of everything. For example, if we pass a const BEntry * to our target searching function, we still need to keep a copy of the entire BEntry instance in the function object, since the original BEntry might be long gone when our thread does its job.

Passing a BEntry as a parameter would be inefficient; it would cause multiple unnecessary copy operations. The BEntry specialization of ParameterBinder ensures that const BEntry * can be passed to the function object constructor, a copy of the BEntry is saved, and a const BEntry * is passed to the target function, which is exactly what we need.

Default ParameterBinder used for scalars:

template<class P>
class ParameterBinder {
public:
  ParameterBinder(P p)
    :  p(p)
    {}
  P Pass()
    { return p; }
private:
  P p;
};

ParameterBinder specialization for const BEntry *:

template<>
class ParameterBinder<const BEntry *> {
public:
  ParameterBinder(const BEntry * p)
    :  p(*p)
    {}
  const BEntry *Pass()
    { return &p; }
private:
  BEntry p;
};

In a real application you'd have a whole army of function object templates and would just pick the one for the right number of function arguments. You wouldn't need to worry about picking the right ParameterBinder once you had specializations for the different struct types you might be using. The function object works on different types of parameters and does full type checking, making sure the types of arguments we pass to it and the types required by the target function are compatible.

If you are interested in knowing more about function objects (or are a function object junkie, like Hiroshi, and can't get enough of them), I recommend that you read, for instance, the excellent "Ruminations about C++," by Andrew Koenig and Barbara Moo.

Here's how we would use our new FireAndForgetThread:

static void
FindAFile(const BEntry *startDir, const char *name)
{
  char buffer[B_FILE_NAME_LENGTH];
  startDir->GetName(buffer);
  printf("looking for %s in directory %s\n", name, buffer);
  // do some work here
}

...
  BEntry entry("/boot/home");
  FireAndForgetThread::Launch(new
    TwoParamFunctionObject<const BEntry *,
    const char *>(&FindAFile, &entry, "APM.h"));
...

The Launch call packages up the function address and the parameters into a function object and sends it off. Note that we didn't need to tweak the thread class itself; all we had to do was supply the FindAFile function itself. There's practically no room left for thread setup code that we could make a mistake in. Oh, and by the way, if we tried really hard to screw up and pass, say an entry_ref * in place of entry, the compiler would catch it because FindAFile takes a const BEntry *. We're reaching the Holy Grail of programming here -- the compiler will not let us make any mistakes.

This concludes the first part of this article, in the next part we'll examine more types of threads and their use.

 


BE ENGINEERING INSIGHTS: BeOS and the Simple Life

By Robert Chinn

Over the weekend I had a chance to tour one of SGI's buildings. It's an impressive site. A huge building with slanted purple walls, a cafeteria, auditoriums, sand volley ball court, nicely landscaped grounds, $1000 chairs, and even clean carpets.

Coming back to Be I realized that while other companies' campuses are distinguished and nice to look at, I really enjoy the simplicity of what we have at Be. In fact, I've made that the subject of this article -- doing things in a simple and direct way; no incredible tricks, no obscure C++, just getting the job done.

Most of our developers have probably already addressed the issue of transitioning from writing code in a straight C or a non-message based system to writing applications for the BeOS. On many systems you have the controls just do the work it needs to do. In the BeOS most of the interactions are message based: a control is pressed and it sends a message that you catch somewhere else and do something.

Sometimes this is the best way to accomplish the task at hand. Other times you just want the control to do something immediately or interact directly with another part of your application directly. Accomplishing this "liveness" with the BeOS is simple and easy, but how and where should it be done? For any object based on BControl, the place to do this is in SetValue.

One example is when you're using a control such as a BColorControl. For this example it will change the Desktop color. In BColorControl's most basic state, when you click on a color tile or change an individual RGB value a message is sent to its parent window:

...

  BColorControl* indirectColorControl = new BColorControl(
	BPoint(0, 0),
	B_CELLS_32x8,
	8,
    "color control",
	new BMessage('ccnt'),
	false);
...

On receiving the message, in our example, the window unpacks the value into an rgb_color and sets the desktop color.

  void
  TWindow::MessageReceived(BMessage* m)
  {
    int32 value;
    rgb_color dcColor;

    switch (m->what) {
      case 'ccnt':  // message sent from BColorControl
        {
          m->FindInt32("be:value", &value);
          dcColor.red   = (value >> 24);
          dcColor.green = (value >> 16);
          dcColor.blue  = (value >> 8);
          dcColor.alpha = 255;

          BScreen b(B_MAIN_SCREEN_ID);
          b.SetDesktopColor(dcColor, true);
        }
        break;
    }
  }

This method works, but is not very "live." Changes to the Desktop color only occur when the mouse button is released, so dragging around on the color tiles doesn't actively change the Desktop color. So, how can you make it "live"? Override the SetValue method of a custom BColorControl, get the rgb value for the current selection and set it directly:

  void
  TCustomColorControl::SetValue(int32 v)
  {
    // always remember to actually set the
    //control's value when overriding
    BColorControl::SetValue(v);

    // convert the value to an rgb_color
    rgb_color dcColor = ValueAsColor();

    // set the desktop color
    BScreen b(B_MAIN_SCREEN_ID);
    b.SetDesktopColor(dcColor, true);
  }

Now, when the user drags around on the color tiles, as each new tile is hit, the Desktop color changes immediately. You can use this same technique with any BControl for a similar "liveness."

In the same way, letting a control "know" about another object enables it to communicate directly and maintain this "live" feel. Here we have a view and a set of three sliders that modify the individual rgb components for the view's view color and the Desktop color:

...

  // get the current Desktop color
  BScreen b(B_MAIN_SCREEN_ID);
  rgb_color dcColor = b.DesktopColor();

  BRect objectFrame(10, 5, Bounds().Width() - 10, 15);

  // create a simple BView as a 'color swatch'
  BView* colorSwatch = new BView(objectFrame,
	"color swatch", B_FOLLOW_NONE, B_WILL_DRAW);
  colorSwatch->SetViewColor(dcColor);
  AddChild(colorSwatch);

  // add 3 custom BSliders, one for the red, green and blue
  // components of an rgb value
  objectFrame.top = 20;
  objectFrame.bottom = 55;
  TSlider* redSlider = new TSlider(objectFrame,
	"Red", colorSwatch);
  AddChild(fRedSlider);

  objectFrame.OffsetBy(0, 40);
  TSlider* greenSlider = new TSlider(objectFrame,
	"Green", colorSwatch);
  AddChild(fGreenSlider);

  objectFrame.OffsetBy(0, 40);
  TSlider* blueSlider = new TSlider(objectFrame,
	"Blue", colorSwatch);
  AddChild(fBlueSlider);

  // set the individual values for each slider
  redSlider->SetValue(dcColor.red);
  greenSlider->SetValue(dcColor.green);
  blueSlider->SetValue(dcColor.blue);
...

Where TSlider is as follows:

  class TSlider : public BSlider {
  public:
    TSlider(BRect frame, const char* name,
      BView* colorSwatch);
    void SetValue(int32 value);

  private:
    BView*  fColorSwatch;
  };

  TSlider::TSlider(BRect frame, const char* name,
    BView* colorSwatch)
    : BSlider(frame, name, name, NULL, 0, 255,
        B_TRIANGLE_THUMB),
      fColorSwatch(colorSwatch)
  {
  }

For our custom slider, the individual rgb components are modified in SetValue, based on which slider was changed. The new rgb value is then used to set the view color for the color swatch and the Desktop color. The fill color for the slider is also set to the individual rgb component that the slider represents:

  void
  TSlider::SetValue(int32 v)
  {
    // tell the slider its new value
    BSlider::SetValue(v);

    // get the current color of the view
    rgb_color viewColor = fColorSwatch->ViewColor();

    // each slider will represent its individual color
    // in its fill color
    rgb_color fillColor = {0,0,0,255};

    // determine which slider has been modified
    // get its new value
    if (strcmp("Red", Name()) == 0) {
      fillColor.red = Value();
      viewColor.red = Value();
    } else if (strcmp("Green", Name()) == 0) {
      fillColor.green = Value();
      viewColor.green = Value();
    } else if (strcmp("Blue", Name()) == 0) {
      fillColor.blue = Value();
      viewColor.blue = Value();
    }

    // set the fill color
    UseFillColor(true, &fillColor);

    // set the view color
    fColorSwatch->SetViewColor(viewColor);
    fColorSwatch->Invalidate();

    // set the Desktop color
    BScreen b(B_MAIN_SCREEN_ID);
    b.SetDesktopColor(viewColor, true);
  }

Since all the processing is done in SetValue, all the changes are "live." Once again, if messages had been used, the color would change only when the mouse button was released.

The above techniques are quite simple -- and that is the point. By simplifying the process with a little directness we make the interaction of the parts of the application a bit more responsive. With this responsiveness comes the "live" feel that most users really appreciate.

 


DEVELOPERS' WORKSHOP: Translation Kit, Again!

By Jon Wattehplus@be.com

"Developers' Workshop" is a weekly feature that provides answers to our developers' questions, or topic requests. To submit a question, visit

http://www.be.com/developers/suggestion_box.html.

 

When I talked about the Translation Kit at the last BeDC, I promised to make code available on our web page that showed how to create a Save As menu using the Translation Kit to save in a format of the user's choice. It's about time I delivered on that promise, so here it is.

In BeOS Release 4, the BTranslationUtils class will have learned new tricks. One of them is to populate an existing BMenu with menu items for available translations, to make creating that Save As menu a no-brainer. However, since anyone who doesn't work here has to live with R3.2 for some time to come, it can't hurt to put the same code in your app, at least until you get around to updating it for R4. Thus, I give you:

#include <Menu.h>
#include <Message.h>
#include <MenuItem.h>
#include <TranslationKit.h>

enum {
  B_TRANSLATION_MENU = 'BTMN'
};

status_t
AddTranslationItems(
  BMenu * intoMenu,
  uint32 from_type,
  const BMessage * model,       /* default B_TRANSLATION_MENU */
  const char * translator_id_name, /* default "be:translator" */
  const char * translator_type_name,     /* default "be:type" */
  BTranslatorRoster * use)
{
  if (use == NULL) {
    use = BTranslatorRoster::Default();
  }
  if (translator_id_name == NULL) {
    translator_id_name = "be:translator";
  }
  if (translator_type_name == NULL) {
    translator_type_name = "be:type";
  }
  translator_id * ids = NULL;
  int32 count = 0;

  status_t err = use->GetAllTranslators(&ids, &count);
  if (err < B_OK)
    return err;

  for (int tix=0; tix<count; tix++) {
    const translation_format * formats = NULL;
    int32 num_formats = 0;
    bool ok = false;
    err = use->GetInputFormats(ids[tix], &formats,
      &num_formats);
    if (err == B_OK)
      for (int iix=0; iix<num_formats; iix++) {
        if (formats[iix].type == from_type) {
          ok = true;
          break;
        }
      }
    if (!ok)
      continue;

    err = use->GetOutputFormats(ids[tix], &formats,
      &num_formats);
    if (err == B_OK)
      for (int oix=0; oix<num_formats; oix++) {
        if (formats[oix].type != from_type) {
          BMessage * itemmsg;
          if (model) {
            itemmsg = new BMessage(*model);
          }
          else {
            itemmsg = new BMessage(B_TRANSLATION_MENU);
          }
          itemmsg->AddInt32(translator_id_name, ids[tix]);
          itemmsg->AddInt32(translator_type_name,
            formats[oix].type);
          intoMenu->AddItem(new BMenuItem(formats[oix].name,
            itemmsg));
        }
      }
  }
  delete[] ids;
  return B_OK;
}

Usage is easy. Create the BMenu that you want to hang off your "Save As" menu item. Call AddTranslationItems() with, at a minimum, that menu and the "class" of formats you deal with. If you deal with bitmaps, this would be B_TRANSLATOR_BITMAP. The last four arguments can be NULL unless you want to customize the operation of the function, which falls outside of the scope of this article (but you can read the code to figure out how).

The function figures out which translators can translate from that "class" format to some interesting format, and add the name of each of those formats as an item to a menu. The message for that item will have a value for the translator (by ID) and the format (by code) requested.

You then do this in your Save As handler, assuming you already know how to run a BFilePanel to tell you where to create an output BFile, and that you're saving a member bitmap named fBitmap:

status_t
MyWindow::DoSaveAs(
  BFile * outputFile,
  BMessage * save_as)
{
  int32 translator;
  uint32 type;
  status_t err;

  err = save_as->FindInt32("be:translator", &translator);
  if (err < B_OK)
    return err;

  err = save_as->FindInt32("be:type", (int32 *)&type);
  if (err < B_OK)
    return err;

  BBitmapStream input(fBitmap);
  err = BTranslatorRoster::Default()->Translate(translator,
    &input, NULL, outputFile, type);
  if (err == B_OK)
    err = SetFileType(outputFile, translator, type);
  return err;
}

As you can see, we're setting the file type by calling another function that we also need to implement. This time to get the actual MIME type corresponding to the internal type ID for the translator we're using:

status_t
MyWindow::SetFileType(BFile * file, int32 translator,
  uint32 type)
{
  translation_format * formats;
  int32 count;

  status_t err = BTranslatorRoster::GetOutputFormats(translator,
    &formats, &count);
  if (err < B_OK)
    return err;

  const char * mime = NULL;
  for (int ix=0; ix<count; ix++) {
    if (formats[ix].type == type) {
      mime = formats[ix].MIME;
      break;
    }
  }
  if (mime == NULL) {
    /* this should not happen, but */
    /* being defensive might be prudent */
    return B_ERROR;
  }

  /* use BNodeInfo to set the file type */
  BNodeInfo ninfo(file);
  return ninfo.SetType(mime);
}

Well, this should fulfill my promise, and I hope it will also lead to a few more applications that can use the Translation Kit to its fullest. We're doing even more interesting stuff with the Translation Kit for R4, including a control panel to let the user configure default settings for installed translators, and an API for your app to retrieve those settings so you can pass them as ioExtension when you call the Translation Kit.

Also in R4 we're defining a standard format for styled text import/export, and writing some more standard Translators to ship with the system. Ah, yes, R4 will be exciting indeed!

 


Are You Insane?

By Jean-Louis Gassée

The question has been asked many times, of me individually and collectively of all of us Be-ans. Often our questioners worry about our well-being. They remind us of the high degree of risk involved in writing "yet another operating system" while Windows reigns supreme or, earlier, while NeXTStep appeared headed in the same direction as OS/2.

If Steve Jobs can't establish a platform, if IBM can't shake Microsoft's hold on the office market, you must be crazy to think you can gain critical mass for yourself and your developers. And by the way, your investors are also crazy if they think they'll ever get back a red cent of their money. It's this kind of feedback that keeps us humble and ever aware that we have much to do and much to prove.

Fortunately, Steve Jobs proved that NeXT was going somewhere. As for us, we've been able to show that the BeOS is not on the same futile path as OS/2 in trying to offer "better DOS than DOS, better Windows than Windows." Rather, in a context that considers the possibility of more than one OS on your hard disk, we are positioning ourselves as a specialized A/V platform coexisting with the general-purpose Windows, a low-risk complement instead of a replacement, as OS/2 attempted to do.

Some still question our sanity about this notion of peaceful OS coexistence, but at least the doubt has shifted. Now it's when we say positive things about Windows and Microsoft that our soundness of mind comes into dispute again. Our concerned correspondents get a little frustrated with us, or with me personally. They ask, "How can you say those things about a product and a company that...," followed by a long, colorful list of sins.

Perhaps this is a good time to explain ourselves. In the first place, we understand and respect the range of opinions and emotions aroused by Microsoft and its products. The company has ascended to preeminence in the industry. Many accuse it of transgressions on its rise to sovereignty. They see flaws in its products. They perceive it as a monopolistic power, and fear what they see as a natural tendency to abuse its position and to crush competition.

Consider the PC market. Today, when you order a PC, it comes with Windows, even when there is an IBM logo on it. In office productivity applications, Microsoft Office is the standard. Why should our little company expend its energy fighting that?

As one of my favorite existential philosophers, Ross Perot, once said, "I don't have a dog in that fight." Well, we don't have a dog in the fight against Microsoft as the general-purpose OS. We're not going to waste our energy on that front. We have too much work to do to improve our product, to create better opportunities for BeOS developers, to service our OEM and distribution partners and, ultimately, to fulfill our shareholders' expectations.

Now put yourself in the position of someone who's just bought a PC. It comes with Windows 98 and, most likely, with some OEM version of Office. We have some choices. If we launch into an anti-Microsoft, anti-Windows diatribe, we run the risk of our potential BeOS user hearing that he or she has just bought a bad product from a bad company.

In our view, this is a bit like meeting someone who's just bought a car and ranting, "You bought that lemon from those felons?" If you're trying to sell this person an after market stereo and on-board DVD player, what do you think your chances are? Since we are, in effect, selling an after market A/V system, it only makes sense to position it as a complement to Windows, rather than disparage the system you just bought. That way, we don't get stuck arguing the pros and cons of Windows. Instead, we move quickly to the merits of our product.

All right then, our questioners persist, you're not insane, but you are hypocritical. You can't tell me you don't have negative feelings towards Microsoft and its products. You're just hiding them because you've calculated it would be bad for business.

Yes, it would be bad for business. Yes, my Jesuit confessor advises me it is permissible to harbor ambivalent feelings towards such a powerful entity. No, my confessor continues, focusing on the positive is not the mortal sin of prevarication, but merely the venial one of californication. For my penance I must say three Hail Marys and two Our Fathers.


Recent Be Newsletters | 1998 Be Newsletters
1997 Be Newsletters | 1995 & 1996 Be Newsletters

Copyright ©1998 Be, Inc. Be is a registered trademark, and BeOS, BeBox, BeWare, GeekPort, the Be logo and the BeOS logo are trademarks of Be, Inc. All other trademarks mentioned are the property of their respective owners. Comments about this site? Please write us at webmaster@be.com.