Goto Chapter: Top 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 Bib Ind
[Top of Book] [Contents] [Previous Chapter] [Next Chapter]

[MathJax off]

80 Examples of Extending the System
80.1 Addition of a Method
80.2 Extending the Range of Definition of an Existing Operation
80.3 Enforcing Property Tests
80.4 Adding a new Operation
80.5 Adding a new Attribute
80.6 Adding a new Representation
80.7 Components versus Attributes
80.8 Adding new Concepts
80.8-1 Example: M-groups
80.8-2 Example: Groups with a word length
80.8-3 Example: Groups with a decomposition as semidirect product
80.9 Creating Own Arithmetic Objects
80.9-1 ArithmeticElementCreator
80.9-2 Example: ArithmeticElementCreator

### 80 Examples of Extending the System

This chapter gives a few examples of how one can extend the functionality of GAP.

They are arranged in ascending difficulty. We show how to install new methods, add new operations and attributes and how to implement new features using categories and representations. (As we do not introduce completely new kinds of objects in these example it will not be necessary to declare any families.) Finally we show a simple way how to create new objects with an own arithmetic.

The examples given are all very rudimentary – no particular error checks are performed and the user interface sometimes is quite clumsy. These examples may be constructed for presentation purposes only and they do not necessarily constitute parts of the GAP library.

Even more complex examples that create whole classes of objects anew will be given in the following two chapters 81 and 82.

#### 80.1 Addition of a Method

The easiest case is the addition of a new algorithm as a method for an existing operation for the existing structures.

For example, assume we wanted to implement a better method for computing the exponent of a nilpotent group (it is the product of the exponents of the Sylow subgroups).

The first task is to find which operation is used by GAP (it is `Exponent` (39.16-2)) and how it is declared. We can find this in the Reference Manual (in our particular case in section 39.16) and the declaration in the library file `lib/grp.gd`. The easiest way to find the place of the declaration is usually to `grep` over all `.gd` and `.g` files, see section 83.

In our example the declaration in the library is:

    DeclareAttribute("Exponent",IsGroup);

Similarly we find that the filter `IsNilpotentGroup` (39.15-3) represents the concept of being nilpotent.

We then write a function that implements the new algorithm which takes the right set of arguments and install it as a method. In our example this installation would be:

    InstallMethod(Exponent,"for nilpotent groups",
      [IsGroup and IsNilpotent],
    function(G)
      [function body omitted]
    end);

We have left out the optional rank argument of `InstallMethod` (78.3-1), which normally is a wise choice –GAP automatically uses an internal ranking based on the filters that is only offset by the given rank. So our method will certainly be regarded as better than a method that has been installed for mere groups or for solvable groups but will be ranked lower than the library method for abelian groups.

That's all. Using 7.2-1 we can check for a nilpotent group that indeed our new method will be used.

When testing, remember that the method selection will not check for properties that are not known. (This is done internally by checking the property tester first.) Therefore the method would not be applicable for the group `g` in the following definition but only for the –mathematically identical but endowed with more knowledge by GAP– group `h`. (Section 80.3 shows a way around this.)

    gap> g:=Group((1,2),(1,3)(2,4));;
    gap> h:=Group((1,2),(1,3)(2,4));;
    gap> IsNilpotentGroup(h); # enforce test
    true
    gap> HasIsNilpotentGroup(g);
    false
    gap> HasIsNilpotentGroup(h);
    true

Let's now look at a slightly more complicated example: We want to implement a better method for computing normalizers in a nilpotent permutation group. (Such an algorithm can be found for example in [LRW97].)

We already know `IsNilpotentGroup` (39.15-3), the filter `IsPermGroup` (43.1-1) represents the concept of being a group of permutations.

GAP uses `Normalizer` (39.11-1) to compute normalizers, however the declaration is a bit more complicated. In the library we find

    InParentFOA( "Normalizer", IsGroup, IsObject, NewAttribute );

The full mechanism of `InParentFOA` (85.2-1) is described in chapter 85, however for our purposes it is sufficient to know that for such a function the actual work is done by an operation `NormalizerOp`, an underlying operation for `Normalizer` (39.11-1) (and all the complications are just there to be able to remember certain results) and that the declaration of this operation is given by the first arguments, it would be:

    DeclareOperation( "NormalizerOp", [IsGroup, IsObject] );

This time we decide to enter a non-default family predicate in the call to `InstallMethod` (78.3-1). We could just leave it out as in the previous call; this would yield the default value, the function `ReturnTrue` (5.4-1) of arbitrary many arguments which always returns `true`. However, then the method might be called in some cases of inconsistent input (for example matrix groups in different characteristics) that ought to fall through the method selection to raise an error.

In our situation, we want the second group to be a subgroup of the first, so necessarily both must have the same family and we can use `IsIdenticalObj` (12.5-1) as family predicate.

Now we can install the method. Again this manual is lazy and does not show you the actual code:

    InstallMethod(NormalizerOp,"for nilpotent permutation groups",IsIdenticalObj,
      [IsPermGroup and IsNilpotentGroup,
       IsPermGroup and IsNilpotentGroup],
    function(G,U)
      [ function body omitted ]
    end);

#### 80.2 Extending the Range of Definition of an Existing Operation

It might be that the operation has been defined so far only for a set of objects that is too restrictive for our purposes (or we want to install a method that takes another number of arguments). If this is the case, the call to `InstallMethod` (78.3-1) causes an error message. We can avoid this by using `InstallOtherMethod` (78.3-2) instead. It is also possible to re-declare an operation with another number of arguments and/or different filters for its arguments.

#### 80.3 Enforcing Property Tests

As mentioned in Section 78.4, GAP does not check unknown properties to test whether a method might be applicable. In some cases one wants to enforce this, however, because the gain from knowing the property outweighs the cost of its determination.

In this situation one has to install a method without the additional property (so it can be tried even if the property is not yet known) and at high rank (so it will be used before other methods). The first thing to do in the actual function then is to test the property and to bail out with `TryNextMethod` (78.5-1) if it turns out to be `false`.

The above `Exponent` (39.16-2) example thus would become:

    InstallMethod(Exponent,"test abelianity", [IsGroup],
      50,# enforced high rank
    function(G)
      if not IsAbelian(G) then
        TryNextMethod();
      fi;
      [remaining function body omitted]
    end);

The value 50 used in this example is quite arbitrary. A better way is to use values that are given by the system inherently: We want this method still to be ranked as high, as if it had the `IsAbelian` (35.4-9) requirement. So we have GAP compute the extra rank of this:

    InstallMethod(Exponent,"test abelianity", [IsGroup],
      # enforced absolute rank of `IsGroup and IsAbelian' installation: Subtract
      # the rank of `IsGroup' and add the rank of `IsGroup and IsAbelian':
      RankFilter(IsGroup and IsAbelian)
      -RankFilter(IsGroup),
    function(G)

the slightly complicated construction of addition and subtraction is necessary because `IsGroup` (39.2-7) and `IsAbelian` (35.4-9) might imply the same elementary filters which we otherwise would count twice.

A somehow similar situation occurs with matrix groups. Most methods for matrix groups are only applicable if the group is known to be finite.

However we should not enforce a finiteness test early (someone else later might install good methods for infinite groups while the finiteness test would be too expensive) but just before GAP would give a no method found error. This is done by redispatching, see 78.6. For example to enforce such a final finiteness test for normalizer calculations could be done by:

    RedispatchOnCondition(NormalizerOp,IsIdenticalObj,
      [IsMatrixGroup,IsMatrixGroup],[IsFinite,IsFinite],0);

#### 80.4 Adding a new Operation

Next, we will consider how to add own operations. As an example we take the Sylow normalizer in a group of a given prime. This operation gets two arguments, the first has to be a group, the second a prime number.

There is a function `IsPrimeInt` (14.4-2), but no property for being prime (which would be pointless as integers cannot store property values anyhow). So the second argument gets specified only as positive integer:

    SylowNormalizer:=NewOperation("SylowNormalizer",[IsGroup,IsPosInt]);

(Note that we are using `NewOperation` (78.1-4) instead of `DeclareOperation` (78.1-5) as used in the library. The only difference other than that `DeclareOperation` (78.1-5) saves some typing, is that it also protects the variables against overwriting. When testing code (when one probably wants to change things) this might be restricting. If this does not bother you, you can use

    DeclareOperation("SylowNormalizer",[IsGroup,IsPosInt]);

as well.)

The filters `IsGroup` (39.2-7) and `IsPosInt` (14.2-2) given are only used to test that `InstallMethod` (78.3-1) installs methods with suitable arguments and will be completely ignored when using `InstallOtherMethod` (78.3-2). Technically one could therefore simply use `IsObject` (12.1-1) for all arguments in the declaration. The main point of using more specific filters here is to help documenting with which arguments the function is to be used (so for example a call `SylowNormalizer(5,G)` would be invalid).

Of course initially there are no useful methods for newly declared operations; you will have to write and install them yourself.

If the operation only takes one argument and has reproducible results without side effects, it might be worth declaring it as an attribute instead; see Section 80.5.

#### 80.5 Adding a new Attribute

Now we look at an example of how to add a new attribute. As example we consider the set of all primes that divide the size of a group.

First we have to declare the attribute:

    PrimesDividingSize:=NewAttribute("PrimesDividingSize",IsGroup);

(See `NewAttribute` (13.5-3)). This implicitly declares attribute tester and setter, it is convenient however to assign these to variables as well:

    HasPrimesDividingSize:=Tester(PrimesDividingSize);
    SetPrimesDividingSize:=Setter(PrimesDividingSize);

Alternatively, there is a declaration command `DeclareAttribute` (13.5-4) that executes all three assignments simultaneously and protects the variables against overwriting:

    DeclareAttribute("PrimesDividingSize",IsGroup);

Next we have to install method(s) for the attribute that compute its value. (This is not strictly necessary. We could use the attribute also without methods only for storing and retrieving information, but calling it for objects for which the value is not known would produce a no method found error.) For this purpose we can imagine the attribute simply as an one-argument operation:

    InstallMethod(PrimesDividingSize,"for finite groups",
      [IsGroup and IsFinite],
    function(G)
      return PrimeDivisors(Size(G));
    end);

The function installed must always return a value (or call `TryNextMethod` (78.5-1)). If the object is in the representation `IsAttributeStoringRep` (13.5-5) this return value once computed will be automatically stored and retrieved if the attribute is called a second time. We don't have to call setter or tester ourselves. (This storage happens by GAP internally calling the attribute setter with the return value of the function. Retrieval is by a high-ranking method which is installed under the condition `HasPrimesDividingSize`. This method was installed automatically when the attribute was declared.)

#### 80.6 Adding a new Representation

Next, we look at the implementation of a new representation of existing objects. In most cases we want to implement this representation only for efficiency reasons while keeping all the existing functionality.

For example, assume we wanted (following [Wie69]) to implement permutation groups defined by relations.

Next, we have to decide a few basics about the representation. All existing permutation groups in the library are attribute storing and we probably want to keep this for our new objects. Thus the representation must be a subrepresentation of `IsComponentObjectRep` (13.4-1) and `IsAttributeStoringRep` (13.5-5). Furthermore we want each object to be a permutation group and we can imply this directly in the representation.

We also decide that we store the degree (the largest point that might be moved) in a component `degree` and the defining relations in a component `relations` (we do not specify the format of relations here. In an actual implementation one would have to design this as well, but it does not affect the declarations this chapter is about).

    DeclareRepresentation(
      "IsPermutationGroupByRelations",
      IsComponentObjectRep and IsAttributeStoringRep and IsPermGroup,
      ["degree","relations"]);

(If we wanted to implement sparse matrices we might for example rather settle for a positional object in which we store a list of the nonzero entries.)

We can make the new representation a subrepresentation of an existing one. In such a case of course we have to provide all structure of this parent representation as well.

Next we need to check in which family our new objects will be. This will be the same family as of every other permutation group, namely the `CollectionsFamily(PermutationsFamily)` (where the family `PermutationsFamily = FamilyObj((1,2,3))` has been defined already in the library).

Now we can write a function to create our new objects. Usually it is helpful to look at functions from the library that are used in similar situations (for example `GroupByGenerators` (39.2-2) in our case) to make sure we have not forgotten any further requirements in the declaration we might have to add here. However in most cases the function is straightforward:

    PermutationGroupByRelations:=function(degree,relations)
    local g
      g:=Objectify(NewType(CollectionsFamily(PermutationsFamily),
                   IsPermutationGroupByRelations),
                   rec(degree:=degree,relations:=relations));
      return g;
    end;

It also is a good idea to install a `PrintObj` (6.3-5) and possibly also a `ViewObj` (6.3-5) method –otherwise testing becomes quite hard:

    InstallMethod(PrintObj,"for perm grps. given by relations",
      [IsPermutationGroupByRelations],
    function(G)
      Print("PermutationGroupByRelations(", G!.degree,",",G!.relations,")");
    end);

Next we have to write enough methods for the new representation so that the existing algorithms can be used. In particular we will have to implement methods for all operations for which library or kernel provides methods for the existing (alternative) representations. In our particular case there are no such methods. (If we would have implemented sparse matrices we would have had to implement methods for the list access and assignment functions, see 21.2.) However the existing way permutation groups are represented is by generators. To be able to use the existing machinery we want to be able to obtain a generating set also for groups in our new representation. This can be done (albeit not very effectively) by a stabilizer calculation in the symmetric group given by the `degree` component. The operation function to use is probably a bit complicated and will depend on the format of the `relations` (we have not specified in this example). In the following method we use `operationfunction` as a placeholder;

    InstallMethod(GeneratorsOfGroup,"for perm grps. given by relations",
      [IsPermutationGroupByRelations],
    function(G)
    local S,U;
      S:=SymmetricGroup(G!.degree);
      U:=Stabilizer(S,G!.relations,  operationfunction );
      return GeneratorsOfGroup(U);
    end);

This is all we must do. Of course for performance reasons one might want to install methods for further operations as well.

#### 80.7 Components versus Attributes

In the last section we introduced two new components, `G!.degree` and `G!.relations`. Technically, we could have used attributes instead. There is no clear distinction which variant is to be preferred: An attribute expresses part of the functionality available to certain objects (and thus could be computed later and probably even for a wider class of objects), a component is just part of the internal definition of an object.

So if the data is of general interest, if we want the user to have access to it, attributes are preferable. Moreover, attributes can be used by the method selection (by specifying the filter `HasAttr` for an attribute `Attr`). They provide a clean interface and their immutability makes it safe to hand the data to a user who potentially could corrupt a components entries.

On the other hand more technical data (say the encoding of a sparse matrix) is better hidden from the user in a component, as declaring it as an attribute would not give any advantage.

Resource-wise, attributes need more memory (the attribute setter and tester are implicitly declared, and one filter bit is required), the attribute access is one further function call in the kernel, thus components might be an immeasurable bit faster.

#### 80.8 Adding new Concepts

Now we look how to implement a new concept for existing objects and fit this in the method selection. Three examples that will be made more explicit below would be groups for which a length of elements (as a word in certain generators) is defined, groups that can be decomposed as a semidirect product and M-groups.

In each case we have two possibilities for the declaration. We can either declare it as a property or as a category. Both are eventually filter(s) and in this way indistinguishable for the method selection. However, the value of a property for a particular object can be unknown at first and later in the session be computed (to be `true` or `false`). This is implemented by reserving two filters for each property, one indicating whether the property value is known, and one, provided the value is known, to indicate the actual boolean value. Contrary to this, the decision whether or not an object lies in a category is taken at creation time and this is implemented using a single filter.

Property:

  Properties also are attributes: If a property value is not known for an object, GAP tries to find a method to compute the property value. If no suitable method is found, an error is raised.

Category:

  An object is in a category if it has been created in it. Testing the category for an object simply returns this value. Existing objects cannot enter a new category later in life. This means that in most cases one has to write own code to create objects in a new category.

  If we want to implement a completely new concept so that new operations are defined only for the new objects –for example bialgebras for which a second scalar multiplication is defined– usually a category is chosen.

  Technically, the behaviour of the category `IsXYZ`, declared as subcategory of `IsABC` is therefore exactly the same as if we would declare `IsXYZ` to be a property for `IsABC` and install the following method:

      InstallMethod(IsXYZ,"return false if not known",[IsABC],ReturnFalse);

  (The word `category` also has a well-defined mathematical meaning, but this does not need to concern us at this point. The set of objects which is defined to be a (GAP) category does not need to be a category in the mathematical sense, vice versa not every mathematical category is declared as a (GAP) category.)

Eventually the choice between category and property often becomes a matter of taste or style.

Sometimes there is even a third possibility (if you have GAP 3 experience this might reflect most closely an object whose operations record is `XYOps`): We might want to indicate this new concept simply by the fact that certain attributes are set. In this case we could simply use the respective attribute tester(s).

The examples given below each give a short argument why the respective solution was chosen, but one could argue as well for other choices.

##### 80.8-1 Example: M-groups

M-groups are finite groups for which all irreducible complex representations are induced from linear representations of subgroups, it turns out that they are all solvable and that every supersolvable group is an M-group. See [Isa76] for further details.

Solvability and supersolvability both are testable properties. We therefore declare `IsMGroup` as a property for solvable groups:

    IsMGroup:=NewProperty("IsMGroup",IsSolvableGroup);

The filter `IsSolvableGroup` (39.15-6) in this declaration only means that methods for `IsMGroup` by default can only be installed for groups that are (and know to be) solvable (though they could be installed for more general situations using `InstallOtherMethod` (78.3-2)). It does not yet imply that M-groups are solvable. We must do this deliberately via an implication and we use the same technique to imply that every supersolvable group is an M-group.

    InstallTrueMethod(IsSolvableGroup,IsMGroup);
    InstallTrueMethod(IsMGroup,IsSupersolvableGroup);

Now we might install a method that tests for solvable groups whether they are M-groups:

    InstallMethod(IsMGroup,"for solvable groups",[IsSolvableGroup],
    function(G)
      [... code omitted. The function must return `true' or `false' ...]
    end);

Note that this example of declaring the `IsMGroup` property for solvable groups is not a part of the GAP library, which uses a similar but different filter `IsMonomialGroup` (39.15-9).

##### 80.8-2 Example: Groups with a word length

Our second example is that of groups for whose elements a word length is defined. (We assume that the word length is only defined in the context of the group with respect to a preselected generating set but not for single elements alone. However we will not delve into any details of how this length is defined and how it could be computed.)

Having a word length is a feature which enables other operations (for example a word length function). This is exactly what categories are intended for and therefore we use one.

First, we declare the category. All objects in this category are groups and so we inherit the supercategory `IsGroup` (39.2-7):

    DeclareCategory("IsGroupWithWordLength",IsGroup);

We also define the operation which is enabled by this category, the word length of a group element, which is defined for a group and an element (remember that group elements are described by the category `IsMultiplicativeElementWithInverse` (31.14-13)):

    DeclareOperation("WordLengthOfElement",[IsGroupWithWordLength,
      IsMultiplicativeElementWithInverse]);

We then would proceed by installing methods to compute the word length in concrete cases and might for example add further operations to get shortest words in cosets.

##### 80.8-3 Example: Groups with a decomposition as semidirect product

The third example is groups which have a (nontrivial) decomposition as a semidirect product. If this information has been found out, we want to be able to use it in algorithms. (Thus we do not only need the fact that there is a decomposition, but also the decomposition itself.)

We also want this to be applicable to every group and not only for groups which have been explicitly constructed via `SemidirectProduct` (49.2-1).

Instead we simply declare an attribute `SemidirectProductDecomposition` for groups. (Again, in this manual we don't go in the details of how such an decomposition would look like).

    DeclareAttribute("SemidirectProductDecomposition",IsGroup);

If a decomposition has been found, it can be stored in a group using `SetSemidirectProductDecomposition`. (At the moment all groups in GAP are attribute storing.)

Methods that rely on the existence of such a decomposition then get installed for the tester filter `HasSemidirectProductDecomposition`.

#### 80.9 Creating Own Arithmetic Objects

Finally let's look at a way to create new objects with a user-defined arithmetic such that one can form for example groups, rings or vector spaces of these elements. This topic is discussed in much more detail in chapter 82, in this section we present a simple approach that may be useful to get started but does not permit you to exploit all potential features.

The basic design is that the user designs some way to represent her objects in terms of GAPs built-in types, for example as a list or a record. We call this the defining data of the new objects. Also provided are functions that perform arithmetic on this defining data, that is they take objects of this form and return objects that represent the result of the operation. The function `ArithmeticElementCreator` (80.9-1) then is called to provide a wrapping such that proper new GAP-objects are created which can be multiplied etc. with the default infix operations such as `*`.

##### 80.9-1 ArithmeticElementCreator

`‣ ArithmeticElementCreator`( spec ) | ( function )

offers a simple interface to create new arithmetic elements by providing functions that perform addition, multiplication and so forth, conforming to the specification spec. `ArithmeticElementCreator` creates a new category, representation and family for the new arithmetic elements being defined, and returns a function which takes the defining data of an element and returns the corresponding new arithmetic element.

spec is a record with one or more of the following components:

`ElementName`

  string used to identify the new type of object. A global identifier `IsElementName` will be defined to indicate a category for these now objects. (Therefore it is not clever to have blanks in the name). Also a collections category is defined. (You will get an error message if the identifier `IsElementName` is already defined.)

`Equality`, `LessThan`, `One`, `Zero`, `Multiplication`, `Inverse`, `Addition`, `AdditiveInverse`

  functions defining the arithmetic operations. The functions interface on the level of defining data, the actual methods installed will perform the unwrapping and wrapping as objects. Components are optional, but of course if no multiplication is defined elements cannot be multiplied and so forth.

  There are default methods for `Equality` and `LessThan` which simply calculate on the defining data. If one is defined, it must be ensured that the other is compatible (so that \(a < b\) implies not \((a = b)\))

`Print`

  a function which prints the object. By default, just the defining data is printed.

`MathInfo`

  filters determining the mathematical properties of the elements created. A typical value is for example `IsMultiplicativeElementWithInverse` for group elements.

`RepInfo`

  filters determining the representational properties of the elements created. The objects created are always component objects, so in most cases the only reasonable option is `IsAttributeStoringRep` to permit the storing of attributes.

All components are optional and will be filled in with default values (though of course an empty record will not result in useful objects).

Note that the resulting objects are not equal to their defining data (even though by default they print as only the defining data). The operation `UnderlyingElement` can be used to obtain the defining data of such an element.

##### 80.9-2 Example: ArithmeticElementCreator

As the first example we look at subsets of \(\{ 1, \ldots, 4 \}\) and define an addition as union and multiplication as intersection. These operations are both commutative and we want the resulting elements to know this.

We therefore use the following specification:

    gap> # the whole set
    gap> w := [1,2,3,4];
    [ 1, 2, 3, 4 ]
    gap> PosetElementSpec :=rec(
    >   # name of the new elements
    >   ElementName := "PosetOn4",
    >   # arithmetic operations
    >   One := a -> w,
    >   Zero := a -> [],
    >   Multiplication := function(a, b) return Intersection(a, b); end,
    >   Addition := function(a, b) return Union(a, b); end,
    >   # Mathematical properties of the elements
    >   MathInfo := IsCommutativeElement and
    >               IsAssociativeElement and
    >               IsAdditivelyCommutativeElement
    > );;
    gap> mkposet := ArithmeticElementCreator(PosetElementSpec);
    function( x ) ... end

Now we can create new elements, perform arithmetic on them and form domains:

    gap> a := mkposet([1,2,3]);
    [ 1, 2, 3 ]
    gap> CategoriesOfObject(a);
    [ "IsExtAElement", "IsNearAdditiveElement",
      "IsNearAdditiveElementWithZero", "IsAdditiveElement",
      "IsExtLElement", "IsExtRElement", "IsMultiplicativeElement",
      "IsMultiplicativeElementWithOne", "IsAssociativeElement",
      "IsAdditivelyCommutativeElement", "IsCommutativeElement",
      "IsPosetOn4" ]
    gap> a=[1,2,3];
    false
    gap> UnderlyingElement(a)=[1,2,3];
    true
    gap> b:=mkposet([2,3,4]);
    [ 2, 3, 4 ]
    gap> a+b;
    [ 1 .. 4 ]
    gap> a*b;
    [ 2, 3 ]
    gap> s:=Semigroup(a,b);
    <commutative semigroup with 2 generators>
    gap> Size(s);
    3

The categories `IsPosetOn4` and `IsPosetOn4Collection` can be used to install methods specific to the new objects.

    gap> IsPosetOn4Collection(s);
    true

[Top of Book] [Contents] [Previous Chapter] [Next Chapter]
Goto Chapter: Top 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 Bib Ind

generated by GAPDoc2HTML