39. pyxc: Visibility

What I Am Building

Chapter 38 added constructors. Classes can now be initialized, but every field and method is accessible from anywhere. After this chapter, a class can hide its internals:

extern def printd(x: float64)

class BoundedCounter:
  private count: int
  private limit: int

  def __init__(max: int):
    self.count = 0
    self.limit = max

  public def increment():
    if self.count < self.limit:
      self.count = self.count + 1

  public def get() -> int:
    return self.count


def main() -> int:
  var c: BoundedCounter = BoundedCounter(3)
  c.increment()
  c.increment()
  c.increment()
  c.increment()    # no effect — limit reached
  printd(float64(c.get()))
  return 0
3.000000

Accessing c.count directly from outside the class is an error.

Source Code

git clone --depth 1 https://github.com/alankarmisra/pyxc-llvm-tutorial
cd pyxc-llvm-tutorial/code/chapter-39

Grammar

class-member gains an optional visibility prefix. visibility is a new production:

*...
*class-block                       = indent class-member
*                                    { end-of-lines class-member } dedent ;
-class-member                      = field-declaration | method-definition ;
+class-member                      = [ visibility ]
+                                    ( field-declaration | method-definition ) ;
+visibility                        = "public" | "private" ;
*field-declaration                 = name ":" type ;
*method-definition                 = "def" name "(" [ parameters ] ")"
*...

New Tokens

*  tok_minus_minus = -63,
*  tok_class = -64,
+  tok_public = -65,
+  tok_private = -66,
*
*  // punctuation and operators

Both are registered in the keyword table and in the token-name map, so error messages print 'public' and 'private'.

Storing Visibility Information

Visibility lands in two places on StructTypeInfo. Fields gain an IsPublic flag directly on StructFieldInfo:

struct StructFieldInfo {
  string Name;
  ValueType Type;
  string StructName;
  bool IsPublic = true;
};

Methods are tracked separately, in a map from method name to visibility:

struct StructTypeInfo {
  vector<StructFieldInfo> Fields;
  map<string, size_t> FieldIndices;
  map<string, bool> Methods;
  bool IsClass = false;
};

Methods need their own map rather than a flag next to the field, since a method's signature lives in FunctionSignatures, not in StructTypeInfo::Fields — there's no single struct visibility could hang off of the way IsPublic hangs off StructFieldInfo.

Parsing Visibility Modifiers

The body loop inside ParseAggregateDefinition now reads an optional visibility token before deciding whether the member is a field or a method:

*    if (CurrentToken == tok_block_end) {
*      getNextToken();
*      continue;
*    }
+    bool IsPublic = true;
+    if (CurrentToken == tok_public || CurrentToken == tok_private) {
+      if (!Info.IsClass) {
+        LogErrorExpression(
+            "Visibility modifiers are only allowed inside class bodies");
+        return false;
+      }
+      IsPublic = CurrentToken == tok_public;
+      getNextToken(); // eat visibility modifier
+    }
*    if (CurrentToken == tok_def) {
*      if (!Info.IsClass) {
*        LogErrorExpression("Methods are only allowed inside classes");
*        return false;
*      }
-      auto Method = ParseMethodDefinition(AggregateName);
+      auto Method = ParseMethodDefinition(AggregateName, IsPublic);
*      if (!Method)
*        return false;

With no modifier, IsPublic stays true: the default is public. A modifier inside a struct body is rejected immediately, before the parser even eats the token, let alone looks at what follows it.

A field's visibility rides along with everything else already pushed into Info.Fields:

*    Info.FieldIndices[FieldName] = Info.Fields.size();
-    Info.Fields.push_back({FieldName, FieldType, FieldStructName});
+    Info.Fields.push_back(
+        {FieldName, FieldType, FieldStructName, IsPublic});
*    StructTypes[AggregateName] = Info;

A method's visibility is passed as an extra argument into ParseMethodDefinition, which now takes bool IsPublic and records it directly:

-static unique_ptr<FunctionDefinitionNode>
-ParseMethodDefinition(const string &ClassName) {
+static unique_ptr<FunctionDefinitionNode>
+ParseMethodDefinition(const string &ClassName, bool IsPublic) {
*  ...
*  auto Signature = make_unique<FunctionSignatureNode>(
*      MangledName, std::move(Parameters), SignatureLocation, ReturnType,
*      std::move(ParameterTypeInfo), ReturnTypeInfo);
*  FunctionSignatures[MangledName] = Signature->clone();
-  StructTypes[ClassName].Methods[MethodName] = true;
+  StructTypes[ClassName].Methods[MethodName] = IsPublic;
*
*  ReturnTypeGuard ReturnGuard(ReturnType, ReturnTypeInfo);
*  ...
*}

Info.Methods is copied back out of StructTypes[AggregateName] after every method (Info.Methods = StructTypes[AggregateName].Methods;), for the same reason Chapter 37 already re-registers StructTypes[AggregateName] = Info after every member: methods parsed earlier in the body need to stay visible while later members are parsed, and vice versa.

Classes Can Have Zero Fields

This chapter also loosens a rule from Chapter 36: the "at least one field" check now excludes classes.

-  if (Info.Fields.empty()) {
+  if (Info.Fields.empty() && !Info.IsClass) {
     LogErrorExpression((string(KindName) + " requires at least one field"));
     return false;
   }

A struct still needs at least one field, since a struct with none would have no reason to exist. A class can now consist entirely of methods with no fields at all, since methods, not fields, are what visibility and this chapter's access checks are really about. I compiled a class with a single method and no fields to confirm: it produces %struct.Empty = type {} in the IR, and calls to its method still work normally.

Enforcing Private Access

Access is decided by one small function:

static string CurrentClassScopeName;

static bool CanAccessClassMember(const string &OwnerClass, bool IsPublic) {
  return IsPublic || (!CurrentClassScopeName.empty() &&
                      CurrentClassScopeName == OwnerClass);
}

A member is reachable if it's public, or if the code currently being parsed belongs to the same class the member is on. "Currently being parsed" is CurrentClassScopeName, set and restored by an RAII guard:

struct ClassScopeGuard {
  string SavedClassName;
  ClassScopeGuard(const string &ClassName)
      : SavedClassName(CurrentClassScopeName) {
    CurrentClassScopeName = ClassName;
  }
  ~ClassScopeGuard() { CurrentClassScopeName = SavedClassName; }
};

ParseMethodDefinition instantiates a ClassScopeGuard before parsing the method's body. When the method is done, the destructor restores whatever CurrentClassScopeName was before — empty at the top level, since pyxc has no nested classes to restore into instead.

Access Checks at Every Use Site

CanAccessClassMember is checked wherever the parser resolves a class member, which turns out to be four places, not one:

Field access, in both places a .field chain gets resolved. ParseFieldExpressionWithBase walks a chain that starts from an already-typed base (used for the auto-deref case, where self is ptr[SomeStruct] and the pointee is decoded before the walk begins):

*static unique_ptr<ExpressionNode>
*ParseFieldExpressionWithBase(const string &BaseName, ValueType BaseType,
*                             string BaseStructName) {
*  ...
*  while (CurrentToken == tok_dot) {
*    getNextToken(); // eat '.'
*    ...
*    const auto &Field = Struct->second.Fields[FieldIndex->second];
+    if (!CanAccessClassMember(ResultStructName, Field.IsPublic))
+      return LogErrorExpression(
+          ("Field '" + FieldName + "' is private on '" +
+           ResultStructName + "'")
+              .c_str());
*    ResultType = Field.Type;
*    ResultStructName = Field.StructName;
*    FieldPath.push_back(FieldName);
*    ...
*  }
*  ...
*}

ParseNameExpressionWithName has its own inline dot-dispatch loop for the ordinary name.field case, with the identical check inline:

*    while (CurrentToken == tok_dot || CurrentToken == tok_lbracket) {
*      if (CurrentToken == tok_dot) {
*        ...
*        string MemberName = Name;
*        getNextToken(); // eat member name
*        if (CurrentToken == tok_lparen) {
*          Result = ParseMethodCallExpression(std::move(Result), MemberName);
*          ...
*        }
*        auto Struct = StructTypes.find(BaseStructName);
*        ...
*        const auto &FieldInfo = Struct->second.Fields[Field->second];
+        if (!CanAccessClassMember(BaseStructName, FieldInfo.IsPublic))
+          return LogErrorExpression(
+              ("Field '" + MemberName + "' is private on '" +
+               BaseStructName + "'")
+                  .c_str());
*        Result = make_unique<MemberExpressionNode>(
*            std::move(Result), Field->second, FieldInfo.Type,
*            FieldInfo.StructName);
*        continue;
*      }
*      ...
*    }

Both paths reject reading and writing a private field, since assignment and read both resolve the field chain through one of these two places before anything else happens.

Method call, in ParseMethodCallExpression, right after resolving ClassName.MethodName:

*static unique_ptr<ExpressionNode>
*ParseMethodCallExpression(unique_ptr<ExpressionNode> Receiver,
*                          const string &MethodName) {
*  ...
*  string ClassName = Receiver->getStructName();
*  auto Class = StructTypes.find(ClassName);
*  if (Class == StructTypes.end() || !Class->second.IsClass)
*    return LogErrorExpression("Method call base must be a class value");
+  auto Visibility = Class->second.Methods.find(MethodName);
+  if (Visibility != Class->second.Methods.end() &&
+      !CanAccessClassMember(ClassName, Visibility->second))
+    return LogErrorExpression(
+        ("Method '" + MethodName + "' is private on '" + ClassName + "'")
+            .c_str());
*
*  string CalleeName = ClassName + "." + MethodName;
*  ...
*}

Constructor call, in ParseNameExpressionWithName, guarded by whether __init__ exists at all:

*  // A class name in call position constructs a value of that class.
*  auto Class = StructTypes.find(ParsedName);
*  if (Class != StructTypes.end() && Class->second.IsClass) {
*    getNextToken(); // eat '('
*    string InitializerName = ParsedName + ".__init__";
*    FunctionSignatureNode *Initializer =
*        GetFunctionSignature(InitializerName);
+    if (Initializer) {
+      auto Visibility = Class->second.Methods.find("__init__");
+      if (Visibility != Class->second.Methods.end() &&
+          !CanAccessClassMember(ParsedName, Visibility->second))
+        return LogErrorExpression(
+            ("Method '__init__' is private on '" + ParsedName + "'").c_str());
+    }
*    vector<unique_ptr<ExpressionNode>> Arguments;
*    ...
*  }

A private __init__ makes ClassName(args) fail from outside the class, the same way a private method or field would.

IR Is Unchanged

Visibility is enforced entirely while parsing. Nothing changes in the generated IR: public and private leave no trace in the output. A private field and a public field of the same type generate identical IR.

Known Limitations

There is no protected. Access is either class-private or world-public; there's no subclass-visible middle tier, since pyxc has no inheritance.

Visibility modifiers on structs are rejected outright. struct members are always public. The parser errors the moment it sees public or private before a struct member, rather than silently ignoring the modifier.

Build and Run

cd code/chapter-39
cmake -S . -B build && cmake --build build
./build/pyxc
llvm-lit -v test/

Try It

Private field, accessed from outside

class Foo:
  private x: int

def main() -> int:
  var f: Foo
  f.x = 3
  return 0
Error (Line 6, Column 7): Field 'x' is private on 'Foo'

Private constructor, called from outside

class Foo:
  x: int
  private def __init__():
    self.x = 0

def main() -> int:
  var f: Foo = Foo()
  return 0
Error (Line 7, Column 20): Method '__init__' is private on 'Foo'

Visibility modifier on a struct

struct Foo:
  private x: int
Error (Line 2, Column 3): Visibility modifiers are only allowed inside class bodies

What's Next

Chapter 40 adds traits.

Need Help?

Build issues? Questions?

Include:

  • Your OS and version
  • Full error message
  • Output of cmake --version, ninja --version, and llvm-config --version

I'll help you figure it out.