3. Functions, Control Flow, and Collections in Jac#
In this chapter, we will explore Jac's enhanced syntax and type system, focusing on functions, control flow, and collections. We will build upon the foundation laid in the previous chapters, introducing more complex data structures and control mechanisms.
Functions and Type Annotations#
Functions are reusable blocks of code that perform a specific task. In Jac, functions are defined using the def
keyword, followed by the function name and its parameters. Type annotations are mandatory for all function parameters and return types.
The following example demonstrates a simple function called add_numbers
that takes two integers as arguments and adds them together, returning the result as an integer. The function is defined with type annotations for both parameters and the return type, ensuring type safety and clarity.
Basic Calculator Program#
Let's build a simple calculator to demonstrate Jac's function in action. The calculator consists of 4 functions that represent basic arithmetic operations: addition, subtraction, multiplication, and division. Each function takes two float arguments and returns the result.
# calculator.jac
def add(a: float, b: float) -> float {
return a + b;
}
def subtract(a: float, b: float) -> float {
return a - b;
}
def multiply(a: float, b: float) -> float {
return a * b;
}
def divide(a: float, b: float) -> float {
return a / b;
}
with entry {
print("=== Simple Calculator ===");
# Test calculations
num1: float = 10.0;
num2: float = 3.0;
print(f"{num1} + {num2} = {add(num1, num2)}");
print(f"{num1} - {num2} = {subtract(num1, num2)}");
print(f"{num1} * {num2} = {multiply(num1, num2)}");
print(f"{num1} / {num2} = {divide(num1, num2)}");
}
More observant readers may notice that the divide
function lacks error handling for division by zero—a common beginner mistake. We'll cover proper error handling techniques later in this chapter. In the meantime, use the divide
function with care, especially when the second argument might be zero. After all, triggering a division-by-zero error might not actually launch a nuclear warhead... but let's not take any chances, shall we?
Basic Object Oriented Programming#
Jac is primarily an Object Spatial Language, but it also supports Object Oriented Programming (OOP) concepts. An object is a self-contained unit that combines data and behavior. In Jac, we can define objects using the obj
keyword. Objects can also have methods which are defined using the def
keyword and these represent the behavior of the object. Objects can also have attributes, which are defined using the has
keyword. These attributes represent the data associated with the object.
Defining an Object#
obj Student {
has name: str;
has age: int;
has gpa: float;
def get_info() -> str {
return f"Name: {self.name}, Age: {self.age}, GPA: {self.gpa}";
}
}
with entry {
student: Student = Student("Alice", 20, 3.8); # Create a new Student object
print(student.get_info());
}
Hey, where is the constructor? Good question! Jac does not have a constructor in the traditional sense. Instead, the object is initialized with the has
keyword, and the attributes are set directly. This is a design choice in Jac to keep things simple and straightforward.
Note
Jac's objects operates similar to data classes in Python, where the attributes are defined at the class level and can be set directly when creating an instance of the object. This allows for a more concise syntax while still providing the benefits of encapsulation and data organization.
Enhanced Calculator with Object-Oriented Design#
In a later chapter, we will explore more of Jac's object-oriented features. For now, let's enhance our calculator by encapsulating its functionality in a obj
called Calculator
. This allows us to maintain a history of calculations and provides a cleaner interface for users.
# oop_calculator.jac
obj Calculator {
has history: list[str] = [];
def add(a: float, b: float) -> float {
result: float = a + b;
self.history.append(f"{a} + {b} = {result}");
return result;
}
def subtract(a: float, b: float) -> float {
result: float = a - b;
self.history.append(f"{a} - {b} = {result}");
return result;
}
def get_history() -> list[str] {
return self.history;
}
def clear_history() {
self.history = [];
}
}
with entry {
calc = Calculator();
# Perform calculations
result1: float = calc.add(5.0, 3.0);
result2: float = calc.subtract(10.0, 4.0);
print(f"Results: {result1}, {result2}");
# Show history
print("Calculation History:");
for entry in calc.get_history() {
print(f" {entry}");
}
}
While Jac styles itself as a Object Spatial Language, it is important to note that it is not opposed to Object Oriented Programming. In fact, Jac supports both paradigms, allowing you to choose the best approach for your project. Think of Object Spatial as Object Oriented with rocket pack boosters. You can still use all of your OOP goodness, but with the added benefits of Jac's spatial features.
Variable Scope and Global Variables#
Jac supports both local and global variables. Local variables are defined within a block and are not accessible outside it, while global variables can be accessed anywhere in the code.
Local Variables#
def add_numbers(a: int, b: int) -> int {
result: int = a + b; # Local variable
return result;
}
with entry {
sum = add_numbers(5, 10);
print(f"Sum: {sum}");
}
Global Variables#
Global variables in Jac are declared using the glob
keyword and are accessible from anywhere in the program. They are typically used for defining constants or sharing data across multiple contexts. However, their use should be limited, as excessive reliance on global state can lead to unintended side effects. This aligns with Jac’s core philosophy of moving computation to the data—favoring localized logic and context-aware execution over broad, global manipulation.
glob school_name: str = "Jac High School";
glob passing_grade: int = 60;
glob honor_threshold: float = 3.5;
def get_school_info() -> str {
:g: school_name; # Accessing global variable
return f"Welcome to {school_name}";
}
with entry {
print(get_school_info());
print(f"Honor threshold is {honor_threshold}");
}
Collections and Data Structures#
Since Jac is a super-set of Python, it supports the same collection types: lists, dictionaries, sets, and tuples. However, Jac enforces type annotations for all collections, ensuring type safety and clarity.
Lists#
Lists are ordered collections of items that can be of mixed types. In Jac, lists are declared with the list
type.
with entry {
# Create an empty list for storing integer grades
alice_grades: list[int] = [];
# Append grades to the list
alice_grades.append(88); # [88]
alice_grades.append(92); # [88, 92]
alice_grades.append(85); # [88, 92, 85]
# Access grades by index
first_grade: int = alice_grades[0]; # 88
print(f"Alice's first grade: {first_grade}");
# print the entire list of grades
print(f"Alice's grades: {alice_grades}");
}
Dictionaries#
Dictionaries are key-value pairs that allow for fast lookups. In Jac, dictionaries are declared with the dict
type.
with entry {
# Class gradebook
math_grades: dict[str, int] = {
"Alice": 92,
"Bob": 85,
"Charlie": 78
};
# Access grades by student name
print(f"Alice's Math grade: {math_grades['Alice']}");
print(f"Bob's Math grade: {math_grades['Bob']}");
print(f"Charlie's Math grade: {math_grades['Charlie']}");
}
Sets#
Sets are unordered collections of unique items. In Jac, sets are declared with the set
type.
with entry {
# Track unique courses
alice_courses: set[str] = {"Math", "Science", "English"};
bob_courses: set[str] = {"Math", "History", "Art"};
# Find common courses
common_courses = alice_courses.intersection(bob_courses);
print(f"Common courses: {common_courses}");
# All unique courses
all_courses = alice_courses.union(bob_courses);
print(f"All courses: {all_courses}");
}
In the example above, we use the
intersection
method to identify courses that both Alice and Bob are enrolled in, and the union
method to combine their courses into a single set of unique entries. These are standard operations provided by Python’s built-in set
type, and Jac supports them as well. For a more comprehensive overview of collection-related functions in Python, refer to the official Python documentation.
Collection Comprehensions#
Jac supports list and dictionary comprehensions, allowing for concise and expressive data processing. In the following example we will explore how to use comprehensions to filter and transform data in a gradebook.
Lets say we have a list of test scores stored in the variable test_scores
and we want to filter out passing grades:
with entry {
# Raw test scores
test_scores: list[int] = [78, 85, 92, 69, 88, 95, 72];
# Get passing grades (70 and above)
passing_scores: list[int] = [score for score in test_scores if score >= 70];
print(f"Passing scores: {passing_scores}");
}
The list comprehension syntax in Jac is similar to Python:
[expression for item in iterable if condition]
where expression
is the value to include in the new list, item
is the variable representing each element in the original collection, iterable
is the collection being processed, and condition
is an optional filter.
Suppose we want to apply a curve to the scores by adding 5 points to each score, we can use a comprehension to create a new list of curved scores:
with entry {
# Raw test scores
test_scores: list[int] = [78, 85, 92, 69, 88, 95, 72];
# Get passing grades (70 and above)
passing_scores: list[int] = [score for score in test_scores if score >= 70];
print(f"Passing scores: {passing_scores}");
# Apply curve (+5 points)
curved_scores: list[int] = [score + 5 for score in test_scores];
print(f"Curved scores: {curved_scores}");
}
Control Flow with Curly Braces#
Earlier in this chapter, we defined a simple calculator. However, our divide
function had a critical oversight—it didn’t account for division by zero. In the interest of both robust code and global stability, we’ll now implement proper control flow to safely handle this case and enhance our calculator’s reliability.
If Statements#
An if
statement allows you to execute code conditionally based on whether a certain condition is true. In Jac, we use curly braces {}
to define the block of code that should be executed if the condition is met.
def divide(a: float, b: float) -> float | str {
if b == 0.0 {
return "Error: Cannot divide by zero!";
}
return a / b;
}
In the example above, we check if
b
is zero via the statement if b == 0.0
before performing the division. If it is, we return an error message instead of attempting the division.
Conditional Logic if-elif-else
#
Jac supports if-elif-else
statements for multiple conditions. This allows you to handle various cases in a structured way. Lets extend our simple grading system to classify grades based on score ranges. In the following example, we will classify scores into letter grades by combining if
, elif
, and else
statements along list comprehensions to apply the classification to a list of test scores.
def classify_grade(score: int) -> str {
if score >= 90 {
return "A";
} elif score >= 80 {
return "B";
} elif score >= 70 {
return "C";
} elif score >= 60 {
return "D";
} else {
return "F";
}
}
with entry {
# Raw test scores
test_scores: list[int] = [78, 85, 92, 69, 88, 95, 72];
# Get passing grades (70 and above)
passing_scores: list[int] = [score for score in test_scores if score >= 70];
print(f"Passing scores: {passing_scores}");
# Apply curve (+5 points)
curved_scores: list[int] = [score + 5 for score in test_scores];
print(f"Curved scores: {curved_scores}");
# Classify each score
grades: list[str] = [classify_grade(score) for score in test_scores];
print(f"Grades: {grades}");
}
$ jac run example.jac
Passing scores: [78, 85, 92, 88, 95, 72]
Curved scores: [83, 90, 97, 74, 93, 100, 77]
Grades: ['C', 'B', 'A', 'D', 'B', 'A', 'C']
Working with Loops#
Jac provides multiple loop constructs including traditional for
loops, Jac's unique for-to-by
loops, and clear, structured while
loops.
Traditional For Loops#
The traditional for loop is useful when iterating over collections, such as lists or dictionaries.
def process_class_grades(grades: dict[str, list[int]]) -> None {
for (student, student_grades) in grades.items() {
total = 0;
for grade in student_grades {
total += grade;
}
average = total / len(student_grades);
print(f"{student}: Average = {average}");
}
}
with entry {
class_grades = {
"Alice": [88, 92, 85],
"Bob": [79, 83, 77],
"Charlie": [95, 89, 92]
};
# Process all students
process_class_grades(class_grades);
}
Jac's Unique For-to-by Loops#
Jac introduces the unique for-to-by
loop, allowing clear and explicit iteration control.
with entry {
print("Scaled scores (0-100 to 0-4.0 GPA):");
for score = 60 to score <= 100 by score += 10 {
gpa = (score - 60) * 4.0 / 40.0;
print(f"Score {score} -> GPA {gpa}");
}
}
While Loops#
Jac supports traditional while
loops with clear curly brace syntax for iterative logic.
with entry {
count: int = 1;
total: int = 0;
while count <= 5 {
print(f"Adding {count} to total");
total += count;
count += 1;
}
print(f"Final total: {total}");
}
Pattern Matching for Complex Logic#
Use pattern matching to handle complex grading scenarios elegantly.
def process_grade_input(input: any) -> str {
match input {
case int() if 90 <= input <= 100:
return f"Excellent work! Score: {input}";
case int() if 80 <= input < 90:
return f"Good job! Score: {input}";
case int() if 70 <= input < 80:
return f"Satisfactory. Score: {input}";
case int() if 0 <= input < 70:
return f"Needs improvement. Score: {input}";
case str() if input in ["A", "B", "C", "D", "F"]:
return f"Letter grade received: {input}";
case list() if len(input) > 0:
avg = sum(input) / len(input);
return f"Average of {len(input)} grades: {avg}";
case _:
return "Invalid grade input";
}
}
with entry {
print(process_grade_input(95)); # Number grade
print(process_grade_input("A")); # Letter grade
print(process_grade_input([88, 92, 85])); # List of grades
}
Exception Handling#
Handle errors gracefully when processing student data.
def safe_calculate_gpa(grades: list[int]) -> float {
try {
if len(grades) == 0 {
raise ValueError("No grades provided");
}
total = sum(grades);
return total / len(grades);
} except ValueError as e {
print(f"Error: {e}");
return 0.0;
}
}
def validate_grade(grade: int) -> None {
if grade < 0 or grade > 100 {
raise ValueError(f"Grade {grade} is out of valid range (0-100)");
}
}
with entry {
# Test safe calculation
valid_grades = [85, 90, 78];
gpa = safe_calculate_gpa(valid_grades);
print(f"GPA: {gpa}");
# Test error handling
try {
validate_grade(150); # Invalid grade
} except ValueError as e {
print(f"Validation error: {e}");
}
}
Comments in Jac#
Comments help document your Jac code clearly. Jac supports both single-line and multiline comments.
with entry {
# This is a single-line comment
student_name: str = "Alice";
#*
This is a
multi-line comment.
*#
grades: list[int] = [88, 92, 85];
print(student_name);
print(grades);
}
Project Structure Conventions#
Jac encourages separating interface declarations from implementations, making code more maintainable as projects grow.
As your projects grow, following these conventions will help:
my_project/
├── main.jac # Main program
├── models/
│ ├── user.jac # User interface
│ ├── user.impl.jac # User implementation
│ └── user.test.jac # User tests
└── utils/
├── helpers.jac # Helper functions
└── constants.jac # Application constants
Interface and Implementation Separation#
You many notice that from the project structure above, there is a file user.jac
and user.impl.jac
. This is a common pattern in Jac projects where interfaces are defined separately from their implementations. This allows for better organization and easier testing.
Lets consider a simple example of a user interface and its implementation. The user has a name
and email
attributes, and we want to validate the email format and provide a display name via the get_display_name
and validate
methods.
We can first define the interface in user.jac
:
# user.jac - Interface declaration
obj User {
has name: str;
has email: str;
def validate() -> bool;
def get_display_name() -> str;
}
Next, we implement the interface in user.impl.jac
:
# user.impl.jac - Implementation
impl User.validate {
return "@" in self.email and len(self.name) > 0;
}
impl User.get_display_name {
return f"{self.name} <{self.email}>";
}
Common Beginner Mistakes and Solutions#
Most beginner issues stem from Jac's stricter type requirements compared to Python. Here are the most common mistakes and their solutions.
Issue | Solution |
---|---|
Missing semicolons | Add ; at the end of statements |
Missing type annotations | Add types to all variables: x: int = 5; |
No entry block | Add with entry { ... } for executable scripts |
Python-style indentation | Use { } braces instead of indentation |
Example of Common Fixes#
Someone unfamiliar with Jac might write code like this:
# This won't work - missing types and semicolons
def greet(name) {
return f"Hello, {name}"
}
# Missing entry block
print(greet("World"))
The corrected version of the code would be:
# This works - proper types and syntax
def greet(name: str) -> str {
return f"Hello, {name}";
}
with entry {
print(greet("World"));
}
Complete Example: Simple Grade Book System#
Lets put everything together in a complete example of a simple grade book system. This example will demonstrate Jac's type system, functions, control flow, and collections in action. The grade book will allow adding students, recording grades, calculating averages, and displaying results.
obj GradeBook {
has students: dict[str, list[int]] = {};
def add_student(name: str) -> None;
def add_grade(student: str, grade: int) -> None;
def get_average(student: str) -> float;
def get_all_averages() -> dict[str, float];
}
impl GradeBook.add_student(name: str) -> None {
if name not in self.students {
self.students[name] = [];
print(f"Added student: {name}");
} else {
print(f"Student {name} already exists");
}
}
impl GradeBook.add_grade(student: str, grade: int) -> None {
if grade < 0 or grade > 100 {
print(f"Invalid grade: {grade}");
return;
}
if student in self.students {
self.students[student].append(grade);
print(f"Added grade {grade} for {student}");
} else {
print(f"Student {student} not found");
}
}
impl GradeBook.get_average(student: str) -> float {
if student not in self.students or len(self.students[student]) == 0 {
return 0.0;
}
grades = self.students[student];
return sum(grades) / len(grades);
}
impl GradeBook.get_all_averages() -> dict[str, float] {
averages: dict[str, float] = {};
for (student, grades) in self.students.items() {
if len(grades) > 0 {
averages[student] = sum(grades) / len(grades);
}
}
return averages;
}
with entry {
# Create gradebook
gradebook = GradeBook();
# Add students
gradebook.add_student("Alice");
gradebook.add_student("Bob");
# Add grades
gradebook.add_grade("Alice", 88);
gradebook.add_grade("Alice", 92);
gradebook.add_grade("Bob", 85);
gradebook.add_grade("Bob", 79);
# Get results
all_averages = gradebook.get_all_averages();
for (student, avg) in all_averages.items() {
letter = "A" if avg >= 90 else "B" if avg >= 80 else "C" if avg >= 70 else "F";
print(f"{student}: {avg} ({letter})");
}
}
Wrapping Up#
This chapter introduced Jac's type system, functions, control flow, and collections. We built a simple calculator, explored object-oriented programming, and learned how to handle errors gracefully. We also covered common beginner mistakes and provided solutions to help you avoid them. These foundational concepts will serve as the building blocks for more advanced Jac programming. In the next chapter, we will explore Jac's advanced features, including AI integration and more sophisticated data structures taking advantage of Jac's powerful type system and syntax.
Now that you understand Jac's type system and syntax, let's build more sophisticated programs with functions and AI integration!