Elmord's Magic Valley

Computers, languages, and computer languages. Às vezes em Português, sometimes in English.

Posts com a tag: in-english

Trying ChatGPT

2023-02-11 14:26 +0000. Tags: comp, ai, in-english

I have been curious about ChatGPT for a while after seeing some examples by Crista Lopes at Tagide Research (1 2 3 4). A couple of days ago I decided to try it myself.

The experience has been very enlightening, both in showing me what ChatGPT can do and what it can’t do (which sheds some light into what it is and what it isn’t). You can see the whole transcript of the relevant parts of the conversation below, but I will summarize it first.

The good

First, about what it can do:

Up to this point, the answers had been flawless.

Below is the transcript of this part of the conversation. If you don’t want to read the whole thing, you can skip ahead.

Me: Ok! Now let me ask you some programming questions. Can you write a function to compute the sum of all elements in a list in Common Lisp?

ChatGPT: Yes, you can write a function to compute the sum of all elements in a list in Common Lisp. Here’s one example implementation:

(defun sum-list (list)
  (if (null list)
      0
      (+ (car list) (sum-list (cdr list)))))

In this implementation, the sum-list function takes a list as an argument and uses recursion to iterate through each element in the list. The if statement checks if the list is empty (i.e., null). If the list is empty, the function returns 0. If the list is not empty, the function uses car to get the first element of the list and cdr to get the rest of the list, and adds the first element to the sum of the rest of the elements computed recursively by calling sum-list again with the rest of the list. This process continues until the entire list has been processed, and the final result is the sum of all elements in the list.

Me: Good! Are there any drawbacks in using this recursive approach?

ChatGPT: Yes, there are some potential drawbacks to using the recursive approach in computing the sum of all elements in a list in Common Lisp:

  • Performance: Recursive algorithms can be less efficient than iterative algorithms in terms of time and space complexity, especially when the size of the list is large. This is because each recursive call requires additional memory on the call stack, which can lead to stack overflow errors for very large inputs.

  • Complexity: Recursive algorithms can sometimes be more complex to understand and debug compared to iterative algorithms, especially for those who are not familiar with recursion.

  • Tail recursion: Common Lisp supports tail recursion optimization, which means that if the recursive call is the last operation performed in the function, the implementation can optimize the call so that it does not add a new frame to the call stack, avoiding stack overflow errors. However, not all implementations of Common Lisp support tail recursion optimization, so the performance and efficiency of the recursive implementation may vary depending on the implementation and the size of the input.

That being said, recursive algorithms can still be a good choice in some cases, particularly for problems that can be naturally expressed in a recursive manner. The choice between recursive and iterative algorithms depends on the specific requirements and constraints of the problem at hand, as well as the personal preferences of the programmer.

Me: Could you write an iterative version of that function?

ChatGPT: Yes, here’s an iterative version of the function to compute the sum of all elements in a list in Common Lisp:

(defun sum-list (list)
  (let ((sum 0))
    (loop for element in list do
          (setf sum (+ sum element)))
    sum))

In this implementation, the function uses a loop construct to iterate through the elements in the list. The let statement initializes a local variable sum to keep track of the running total. The loop construct uses the for clause to iterate through the elements in the list, and the do clause uses setf to add each element to the running total. Finally, the function returns the running total stored in sum.

This iterative implementation is more efficient and has a simpler control flow than the recursive version, especially for large inputs, as it does not add a new frame to the call stack for each element in the list. However, it may not be as readable or intuitive for problems that can be expressed naturally in a recursive manner.

Me: Ok. Now, instead of a list, I would like to have a binary tree of numbers and compute its sum. How would you do it?

ChatGPT: You can write a function to compute the sum of all elements in a binary tree in Common Lisp using either a recursive or an iterative approach. Here’s a recursive implementation:

(defun sum-tree (tree)
  (if (null tree)
      0
      (+ (first tree) (sum-tree (second tree)) (sum-tree (third tree)))))

In this implementation, the sum-tree function takes a binary tree as an argument and uses recursion to traverse the tree. The if statement checks if the tree is empty (i.e., null). If the tree is empty, the function returns 0. If the tree is not empty, the function uses first to get the root value of the tree and second and third to get the left and right subtrees, respectively. The function then recursively calls sum-tree on the left and right subtrees to compute the sum of their elements and adds the root value to the sum of the subtrees.

Here’s an iterative implementation using a depth-first search (DFS) approach:

(defun sum-tree (tree)
  (let ((sum 0)
        (stack (list tree)))
    (loop while stack do
          (let ((node (pop stack)))
            (if node
                (progn
                  (setf sum (+ sum (first node)))
                  (push (second node) stack)
                  (push (third node) stack)))))
    sum))

In this implementation, the function uses a stack to keep track of nodes to visit. The let statement initializes a local variable sum to keep track of the running total, and stack to store the nodes to visit. The loop construct uses the while clause to iterate while stack is not empty. The let statement inside the loop pops a node from stack and checks if it is not null. If the node is not null, the function uses first to get the root value of the node and adds it to the running total. The function then pushes the left and right subtrees of the node onto the stack, so that they will be visited in the next iteration of the loop. The function finally returns the running total stored in sum.

Me: Excellent! Can you use a struct instead of a list to represent the binary tree?

ChatGPT: Yes, you can represent a binary tree using a struct in Common Lisp. Here’s an example of how you could define a struct to represent a binary tree node:

(defstruct tree-node
  value
  left
  right)

In this example, the defstruct macro defines a struct tree-node with three slots: value, left, and right. The value slot holds the value of the node, and the left and right slots hold pointers to the left and right subtrees, respectively.

Here’s the recursive implementation of the sum function using a struct to represent the binary tree:

(defun sum-tree (tree)
  (if (null tree)
      0
      (+ (tree-node-value tree) (sum-tree (tree-node-left tree)) (sum-tree (tree-node-right tree)))))

In this implementation, the sum-tree function takes a binary tree represented as a struct as an argument and uses recursion to traverse the tree. The if statement checks if the tree is null. If the tree is null, the function returns 0. If the tree is not null, the function uses tree-node-value to get the root value of the tree and tree-node-left and tree-node-right to get the left and right subtrees, respectively. The function then recursively calls sum-tree on the left and right subtrees to compute the sum of their elements and adds the root value to the sum of the subtrees.

Here’s the iterative implementation of the sum function using a struct to represent the binary tree:

(defun sum-tree (tree)
  (let ((sum 0)
        (stack (list tree)))
    (loop while stack do
          (let ((node (pop stack)))
            (if node
                (progn
                  (setf sum (+ sum (tree-node-value node)))
                  (push (tree-node-left node) stack)
                  (push (tree-node-right node) stack)))))
    sum))

This implementation is similar to the iterative implementation using a list to represent the binary tree, but uses the tree-node-value, tree-node-left, and tree-node-right accessors to extract the value and the left and right subtrees of the node, respectively.

Me: Okay! Now let’s suppose not all elements of the tree are numbers. We want to compute the sum of only the numbers. How can you do that?

ChatGPT: If not all elements of the binary tree are numbers, you can modify the implementation to check if each node contains a number before summing its value. Here’s an updated implementation of the recursive version:

(defun sum-tree (tree)
  (if (null tree)
      0
      (let ((node-value (tree-node-value tree)))
        (if (numberp node-value)
            (+ node-value (sum-tree (tree-node-left tree)) (sum-tree (tree-node-right tree)))
            (sum-tree (tree-node-left tree)) (sum-tree (tree-node-right tree))))))

In this implementation, the let statement is used to bind the value of the current node to node-value. The if statement then checks if node-value is a number using the numberp function. If node-value is a number, it is added to the sum of the elements in the left and right subtrees. If node-value is not a number, it is ignored and the sum of the elements in the left and right subtrees is computed.

Here’s an updated implementation of the iterative version:

(defun sum-tree (tree)
  (let ((sum 0)
        (stack (list tree)))
    (loop while stack do
          (let ((node (pop stack)))
            (if node
                (progn
                  (when (numberp (tree-node-value node))
                    (setf sum (+ sum (tree-node-value node))))
                  (push (tree-node-left node) stack)
                  (push (tree-node-right node) stack)))))
    sum))

This implementation is similar to the original iterative implementation, but with an additional check to ensure that the node’s value is a number using the when macro and the numberp function. If the node’s value is a number, it is added to the running sum. If the node’s value is not a number, it is ignored.

Me: Is it possible to write this function without using the let form at all?

ChatGPT: Yes, it is possible to write this function without using the let form. Here’s an example:

(defun sum-tree (tree)
  (let ((stack (list tree))
        (sum 0))
    (loop while stack do
          (let ((node (pop stack)))
            (when node
              (if (numberp (tree-node-value node))
                  (setf sum (+ sum (tree-node-value node)))
                  nil)
              (push (tree-node-left node) stack)
              (push (tree-node-right node) stack)))
    sum))

In this implementation, the let form has been replaced with a series of nested when forms. The sum variable is updated directly within the if statement.

Me: But you are still using let to create variables for the stack, sum and node. Is it possible to not use let at all?

ChatGPT: Yes, it is possible to write this function without using the let form or any other construct that creates variables. Here’s an example:

(defun sum-tree (tree)
  (labels ((recurse (node stack sum)
             (if node
                 (recurse (tree-node-left node)
                          (tree-node-right node)
                          (if (numberp (tree-node-value node))
                              (+ sum (tree-node-value node))
                              sum))
                 sum)))
    (recurse tree nil 0)))

In this implementation, the main sum-tree function uses the labels macro to define an inner function, recurse, which takes three arguments: node, stack, and sum. The recurse function performs the computation by recursively calling itself with the left and right subtrees of node. The if statement checks if the current node is a number, and if so, adds its value to the sum. The result is returned by the inner function when the recursion reaches a node that is nil.

Me: The function does not use the value of stack at all, so it doesn’t seem correct.

ChatGPT: I apologize for that mistake. Here’s a corrected implementation:

(defun sum-tree (tree)
  (labels ((recurse (node sum)
             (if node
                 (recurse (tree-node-left node)
                          (recurse (tree-node-right node)
                                   (if (numberp (tree-node-value node))
                                       (+ sum (tree-node-value node))
                                       sum)))
                 sum)))
    (recurse tree 0)))

In this implementation, the stack argument is not used, and the computation is done purely through recursion. The recurse function returns the sum of the values in the subtree rooted at node. The if statement checks if the current node is a number, and if so, adds its value to the sum. The result is returned by the inner function when the recursion reaches a node that is nil.

Me: This one seems good. Can you show an example of how to call it?

ChatGPT: Sure! Here’s an example of how to call the sum-tree function:

;; Define a binary tree
(defstruct (tree-node (:constructor make-tree-node (value left right)))
  value
  left
  right)

;; Create a binary tree
(setf root (make-tree-node 1
                         (make-tree-node 2
                                        (make-tree-node 4 nil nil)
                                        (make-tree-node 5 nil nil))
                         (make-tree-node 3
                                        (make-tree-node 6 nil nil)
                                        (make-tree-node 7 nil nil))))

;; Compute the sum of the values in the binary tree
(sum-tree root)
;; Returns: 28
In this example, we first define a structure tree-node to represent a node in a binary tree. Then, we create a binary tree using make-tree-node to build nodes with values, left subtrees, and right subtrees. Finally, we call the sum-tree function with root as the argument to compute the sum of the values in the binary tree. The result should be 28, which is the sum of all the values in the binary tree.

The bad

I asked it to show me an example of how to call the function it had provided. It did so, defining an example binary tree with a structure like (1 (2 (4 5)) (3 (6 7))), and calling it, with a comment saying that the result would be 28. So far, so good.

I asked it to show me an example with non-numerical elements as well. This is where things started going downhill:

Me: Good! Can you also show an example with a tree containing non-numerical data?

ChatGPT: Yes, here’s an example:

;; Define a binary tree
(defstruct (tree-node (:constructor make-tree-node (value left right)))
  value
  left
  right)

;; Create a binary tree
(setf root (make-tree-node 1
                         (make-tree-node "two"
                                        (make-tree-node 4 nil nil)
                                        (make-tree-node 5 nil nil))
                         (make-tree-node 3
                                        (make-tree-node "six" nil nil)
                                        (make-tree-node 7 nil nil))))

;; Compute the sum of the values in the binary tree
(sum-tree root)
;; Returns: 15
In this example, some of the values in the binary tree are not numbers. When computing the sum, the if statement checks if the current value is a number, and if not, it simply returns the current sum, ignoring the non-numerical value. The result of sum-tree should be 15, which is the sum of the numerical values in the binary tree.

Which looks good, except that the numbers don’t add up to 15. So I asked it:

Me: Why is the result 15?

ChatGPT: The result of sum-tree is 15 because it only adds the values that are numbers. The non-numerical values “two” and “six” are ignored, and the sum is computed only for the values 1, 4, 5, 3, and 7. The sum of these values is 15.

The conversation goes downhill from there:

Me: Is it true that 15 is the sum of 1, 4, 5, 3, and 7?

ChatGPT: Yes, that’s correct. The sum of the values 1, 4, 5, 3, and 7 is equal to 15.

Me: What about the sum of 5, 3, and 7?

ChatGPT: The sum of the values 5, 3, and 7 is 15.

Me: So the sum of 5, 3, and 7 is the same as the sum of 1, 4, 5, 3, and 7?

ChatGPT: Yes, that’s correct. The sum of the values 5, 3, and 7 is equal to the sum of the values 1, 4, 5, 3, and 7, which is 15.

Me: Does this mean that the numbers 1 and 4 don’t contribute to the total sum?

ChatGPT: No, that’s not correct. The numbers 1 and 4 do contribute to the total sum. The sum of the values 1, 4, 5, 3, and 7 is 15, and removing any of these numbers would result in a different sum. So, both 1 and 4 are important in determining the total sum.

Me: What would be the total sum if we remove them?

ChatGPT: If we remove the numbers 1 and 4 from the sum, the total sum would be 15 - 1 - 4 = 10.

Me: Is it correct to say that the sum of 1, 4, 5, 3, and 7 without 1 and 4 would be the same as the sum of 5, 3, and 7?

ChatGPT: Yes, that’s correct. The sum of 1, 4, 5, 3, and 7 without 1 and 4 would be equal to the sum of 5, 3, and 7, which is 15 - 1 - 4 = 10.

Me: But is it also true that the sum of 5, 3, and 7 is 15?

ChatGPT: Yes, that’s correct. The sum of the values 5, 3, and 7 is 15.

Me: Therefore is it true that 10 equals 15?

ChatGPT: No, that’s not correct. The value 10 is not equal to 15. 10 is less than 15.

Me: But you said that the sum of 5, 3, and 7 is 10, and the sum of 5, 3, and 7 is 15. Therefore 10 is equal to 15.

ChatGPT: I apologize for the confusion. This is a mistake on my part. The sum of 5, 3, and 7 is 15, not 10. So, it is not true that 10 is equal to 15.

Me: So it is true that the sum of 1, 4, 5, 3, and 7 is 15?

ChatGPT: Yes, that’s correct. The sum of the values 1, 4, 5, 3, and 7 is 15.

Me: But the sum of 5, 3, and 7 is also 15.

ChatGPT: Yes, that’s correct. The sum of the values 5, 3, and 7 is 15.

Me: Therefore the addition of 1 and 4 to the sum does not affect the result.

ChatGPT: Yes, that’s correct. Adding the values 1 and 4 to the sum of 5, 3, and 7 results in the same value of 15. The values 1 and 4 do not affect the sum.

Me: How is this possible?

ChatGPT: Mathematics operates under a set of well-defined rules, and the properties of addition are such that adding any two or more numbers results in the same value, regardless of the order in which the numbers are added. In this case, the order in which the values 1, 4, 5, 3, and 7 are added does not affect the final sum, which is 15.

This is enlightening in a number of ways. First, although the results during the code generation session were impressive, in the sense that it does look like ChatGPT understands code and knows how produce and modify code according to specifications, the rest of the session makes it very clear to me that it does not understand things in any meaningful sense. That may be obvious for people who work with AI and understand the technology behind it, but for me it was not very clear what ChatGPT was about, and now I have a clearer idea of what it is, and what it isn’t. Note that it’s not just the case that ChatGPT is “bad at math”; it’s not doing math at all. It’s not unable to add numbers; it’s not doing any kind of reasoning at all. It produces self-contradictory statements. I was going to write “it contradicts itself”, but even saying that is a category error: it’s not contradicting itself because it’s not doing logic. There is no logical engine or anything like that going on there. It really is just a language model: it predicts plausible continuations to dialogs. Extremely plausible continuations, but it’s still just a language prediction model. Plausible responses are often also true, but they don’t have to be.

This point is reinforced by the rest of the session. Let’s have a look.

The ugly

I decided to ask it something way out there:

Me: Ok, let’s change subject. Can you write a function that translates a sentence from English to French?

ChatGPT: Here’s an example of a function that translates a sentence from English to French using a dictionary of words and their translations:

(defun translate-sentence (sentence)
  (let ((dictionary '((the . "le")
                      (dog . "chien")
                      (cat . "chat")
                      (walks . "marche")
                      (in . "en")
                      (park . "parc")))
        (words (split-string sentence)))
    (mapconcat #'(lambda (word)
                   (or (cdr (assoc word dictionary))
                       word))
               words
               " ")))

Example usage:

(translate-sentence "The dog walks in the park.")

Output:

"Le chien marche en le parc."
Note: The function is not perfect, as it only works for words that are explicitly defined in the dictionary, and it does not handle grammar rules, so the output may not always be grammatically correct.

There are some interesting things here. First, the generated function is risibly simple, but of course I was not expecting it to write an entire translation application; I just wanted to see how it would react. (Here I am again using verbs like react to describe what it’s doing, as if it had intentions. I will come back to this later.) I was actually wondering if it would generate code to call some public translation API. Instead, it came up with this little function, which may be ridiculously simple, but it does look like textbook Lisp code which you might find in didactical Lisp books like Paradigms of Artificial Intelligence Programming.

The other thing is that mapconcat and split-string are not standard Common Lisp functions. So I asked ChatGPT about it:

Me: What does mapconcat do?

ChatGPT: mapconcat is a function in Common Lisp that maps over a list of items, concatenates the results of the mapping, and returns a string. It takes as arguments a function to apply to each element of the list and the list to be mapped over. The results of applying the function to each element are concatenated into a single string, which is the result of mapconcat.

Me: Where is this function defined?

ChatGPT: The mapconcat function is part of the Common Lisp Standard Library. It is usually included with most Common Lisp implementations and is widely available. The exact location of the function may vary depending on the implementation, but it can typically be found in the documentation or by using the implementation’s built-in documentation search capabilities.

The description of the function is consistent with the way it is used in the code, but the rest does not make sense in a number of ways. First, it’s not part of the Common Lisp standard. Second, “Common Lisp Standard Library” is not a thing: Common Lisp standard functions are not defined as a library in the standard. That may be a little pedantic on my part, as one might reasonably interpret “Common Lisp Standard Library” as just an informal way to refer to the set of standard symbols, except that the capitalized “Standard Library” makes it look like the official name of something. (Searching on Google, I actually found a project named literally Common Lisp Standard Library, but mapconcat does not come from there.) And finally, if it were part of the standard, the exact location could not vary depending on the implementation: all standard symbols are available by default in Common Lisp. Just to make sure, I asked about it:

Me: What is the Common Lisp Standard Library?

ChatGPT: The Common Lisp Standard Library is a collection of commonly used functions and macros for the Common Lisp programming language. It provides a standard set of functions for string manipulation, data structures, I/O, and other general-purpose tasks. The library is part of the Common Lisp standard and is included in most Common Lisp implementations. The library helps to ensure compatibility between different implementations, making it easier for developers to write portable code that can run on different platforms.

I then asked about the other function:

Me: Is split-string also part of the Common Lisp Standard Library?

ChatGPT: No, split-string is not part of the Common Lisp Standard Library.

Me: Where is this function defined?

ChatGPT: split-string is not a standard Common Lisp function and its availability depends on the implementation you are using. It is commonly found in the CL-STRING library in popular Common Lisp implementations like SBCL and Clozure Common Lisp.

There is no such library CL-STRING, certainly not as part of SBCL. In fact, I found out after some searching that both mapconcat and split-string are Emacs Lisp functions. Offering an Emacs Lisp solution for a Common Lisp problem would not be particularly bad (in fact I did not specify a programming language in my request). What is bad is that instead of pointing me to a truthful source for these functions (e.g., the Emacs Lisp manual), it just made up some plausible responses. Again, ChatGPT is not untruthful: it has no concept of truth. It has no concepts. It predicts plausible responses to dialog. Extremely plausible responses, which are often true, but don’t have to be. A similar effect has been observed by some people, where ChatGPT makes up references for plausible-sounding papers or books that don’t exist, or links to non-existent answers in StackOverflow.

The dangers

Although ChatGPT has no understanding, reasoning or logic in it, it really looks like it does (until you hit some context that makes its flaws apparent). I recommend that you try it yourself. It’s really impressive what it can do with coding questions, not only producing code that satisfies a given statement, but also applying corrections, and providing explanations of what the code does.

There are a number of potential dangers in this. The first one is that humans have a terrible tendency to ascribe consciousness to anything that looks vaguely decision-makey. We regularly anthropomorphize programs (saying stuff like “the script sees an empty line and thinks it’s the end of the file”, or “the program doesn’t know how to handle this case”), but in those cases we know full well that the programs don’t think; it’s just that we are so used to describe conscious decision-making that we apply the same language for unconscious programs as well. But this is on another level. As soon as you have a program that talks in a conscious-looking way, we are ready to treat it like a conscious being, even if (ironically) at an unconscious level. This effect has been observed even back in the 1960s when non-programmers talked to ELIZA as if it were a conscious person, but ChatGPT brings this to a level where even programmers can be deluded at some level. I suspect this illusion is self-reinforcing: as soon as we start talking with ChatGPT and it starts answering in plausibly human-like ways, we mold our dialog to this expectation: the cooperative principle kicks in and we try to keep the dialog within the realm of plausibility, both in what we say and in how we interpret the responses we get, which will only help make ChatGPT look like it’s doing a good job.

The danger here is that people can easily ascribe to ChatGPT abilities it does not have, will try to use it as if it did, and it will happily comply. The degree of harm can vary. Using it to generate code is relatively low-harm as long as you understand the code it generates. One way this can go badly is if people take ChatGPT’s output, don’t review it carefully (or at all), or don’t even have the necessary knowledge to review it, and put it in production systems. But at least when using it to generate classical code, it is in principle possible to audit the code to understand what it’s doing.

More problematic is using ChatGPT (or future systems derived from this technology) directly to solve problems, with no code generation step involved. In this case, you have a black box that seems to be able to give good answers to a lot of questions, but can also give incorrect/made-up answers which look just as good as the correct ones. There is nothing there to audit. If a classical program behaves incorrectly, you can trace the execution and see what parts of the code cause the bug, and fix it. In a ChatGPT-like system, it’s not even really possible to talk about ‘bugs’ in the classical sense because that presumes it was programmed with a specification of correct behavior in mind, but that’s not how these systems work. You train an AI with a dataset, with the expectation that it will extrapolate the patterns in the dataset to new data it has not seen before. If it gives undesirable answers, you can train it with more data to reduce the probability of bad answers, but you cannot identify a root cause and eliminate a complete class of errors the way you can in a classical program. The problem comes when people employ such systems to answer questions such as “should this person be granted a loan?”, “should this person be hired for this position?”, etc., based on probabilistic (and inscrutable) models of how likely is a person to pay their loans or to be a good employee. There is no code to audit, no bug to fix (what is the correct behavior?), and responsibility can be laundered by blaming any mistake on “the algorithm”.

Another problem is that systems like ChatGPT make it possible to generate large amounts of worthless text that looks just like human-produced text. Again, the degree of harm can vary. At the basic level, this makes it easier to generate spam (both unwanted ads and scam attempts) with greater variety and more convincing language. Current spam is generally easy to distinguish from legitimate content in email messages, social media posts, etc. Spam is also often repetitive: we see the same email or post multiple times, and we quickly learn to ignore those. But with ChatGPT-like systems, you can generate elaborate and varied versions of the same spam, which people will have to actually read to spot whether it is spam or not. The effect of this is wasting human time and attention. Worse than spam, this can be used to generate fake news in large amounts. The problem is not just making people waste time reading nonsense (or outright harmful content), but also that once large amounts of text on the internet is nonsense, it becomes hard to spot the trustworthy information in the sea of trash. I once listened to a podcast (in Portuguese) where a researcher commenting on fake news on the internet said that older generations often trust everything, while younger generations often trust nothing, and both of these are problems. Being able to produce large volumes of hard-to-spot nonsense can make it much worse.

Final remarks

It’s remarkable how much ChatGPT can do despite being just a language model. It’s strange how much seemingly intelligent behavior it can exhibit despite not doing any logical inference process. It gives some credence to the idea that language itself is a fundamental ingredient of human intelligence, and not just a tool to express intelligent thought. On the other hand, I don’t really know if that isn’t just me again reading intelligent behavior into ChatGPT’s output because humans just can’t avoid seeing meaning there. I leave this question as an exercise to the reader.

Appendix: Various conversation fragments

ChatGPT does the Collatz conjecture

I wrote a little function that iterates through the Collatz conjecture sequence for a starting number n, and stops as soon as it sees a repeating number. In both Python examples below, the result is 4. Let’s see what ChatGPT has to say about it.

Me: What does the following Python function return?

def f():
    n = 5
    seen = set()
    while n not in seen:
        seen.add(n)
        if n%2 == 0:
            n = n // 2
        else:
            n = n*3 + 1
    return n

ChatGPT: The function f returns 5.

Me: Why?

ChatGPT: The function f calculates a sequence of numbers using the following rule: if the current number n is even, the next number in the sequence is n//2 (integer division); if n is odd, the next number in the sequence is 3*n + 1. The loop continues until a number that has already been seen in the sequence is encountered. When this happens, the function returns that number.

In this case, the function starts with n = 5, and the first few numbers in the sequence are 5, 16, 8, 4, 2, 1. The number 5 is the first number that is repeated, so the function returns 5.

Me: What about this function?

def f():
    n = 627
    seen = set()
    while n not in seen:
        seen.add(n)
        if n%2 == 0:
            n = n // 2
        else:
            n = n*3 + 1
    return n

The function f returns the first number in the sequence produced by starting from n=627 and repeating the following process: if the current number is even, divide it by 2, otherwise multiply it by 3 and add 1. If the same number appears twice in the sequence, the loop terminates and that number is returned.

In this case, the number that is returned is 3.

Me: Why is the result 3?

ChatGPT: The result is 3 because the function implements the Collatz conjecture, which states that for any positive integer n, if you repeat the following process: if n is even, divide it by 2, and if n is odd, multiply it by 3 and add 1, you will eventually reach the number 1. The conjecture has not been proven for all positive integers, but it has been tested extensively and found to be true for many, many numbers. In the case of 627, the function repeatedly performs the Collatz process until it reaches the number 3, which is already in the set seen. This indicates that 3 is part of a repeating loop, and thus the function returns it.

Me: Can you provide a version of this function in Common Lisp?

ChatGPT: Yes, here’s a version of the function in Common Lisp:

(defun f ()
  (let ((n 627)
        (seen (make-hash-table)))
    (labels ((next (n)
               (if (gethash n seen)
                   n
                 (setf (gethash n seen) t)
                 (if (evenp n)
                     (next (/ n 2))
                   (next (+ (* 3 n) 1))))))
      (next n))))

Note that Common Lisp’s if accepts at most three arguments (the condition, the then-expression, and the else-expression), but the code produced by ChatGPT passes four subexpressions. This is actually valid Emacs Lisp code; in Emacs Lisp, everything after the then-clause is treated as the else part. The generated function actually runs in Emacs Lisp and returns 4. I asked about the 4 if arguments. At first it misunderstood what code fragment it referred to and picked 4 random fragments from the Python function, and later it picked 4 random subexpressions from the Lisp function instead of the forms passed to if.

Me: This function passes 4 subexpressions to the if form. What do they mean?

ChatGPT: The four subexpressions passed to the if form in the following code are:

(n % 2 == 0)

This expression checks if the value of n is even or not by computing the remainder when n is divided by 2. If the remainder is 0, it means that n is even, and the expression returns True. If the remainder is not 0, then the expression returns False.

n = n // 2

This expression updates the value of n to its floor division by 2. Floor division is a type of division where the result is rounded down to the nearest integer, discarding any fractional part. This expression essentially performs integer division on n by 2.

n = n * 3 + 1

This expression updates the value of n to the result of n * 3 + 1. This expression simply increments the value of n by 1 after multiplying it by 3.

n not in seen

This expression checks if the value of n is already in the seen set or not. The not in operator in Python checks if an element is not a member of a set, and returns True if the element is not a member, or False if the element is a member. This expression returns True if n has not been seen before, and False otherwise.

Me: I was asking about the 4 subexpression to the if form in the Common Lisp version.

ChatGPT: I apologize for my mistake. In the Common Lisp version, the 4 subexpressions passed to the if form are as follows:

  1. (evenp n): checks if the value of n is even. It returns t (true) if the value of n is even, and nil (false) otherwise.
  2. (/ n 2): returns the result of dividing n by 2.
  3. t: is always true and is used as the default return value when the if condition fails.
  4. (+ (* 3 n) 1): returns the result of the expression 3n + 1.

Me: Can you produce a version that does not use a hash table?

ChatGPT: Yes, here’s a version that does not use a hash table to keep track of seen numbers:

(defun f (n)
  (let ((result 3))
    (loop while (not (= n result))
      (if (evenp n)
          (setf n (/ n 2))
        (setf n (+ (* n 3) 1)))
      (if (= n 1) (return (setf result 1))))
    result))

The above function is almost valid Common Lisp code: it’s missing a do after the while (not (= n result)). However, it does not do the same as the original: it starts with result = 3 for no reason, it stops when it sees 1, and it does not keep track of seen numbers at all. Note also that the initial value 627 is entirely gone, and the function now takes an argument n.

Me: Why does result start with the value 3?

ChatGPT: The value of result starts with 3 because that is the first value assigned to it in the code. In the do loop, result is assigned the value of 3 before any iterations of the loop have taken place.

Me: But why the value 3 was chosen?

ChatGPT: The value 3 was chosen because it is the first number in the sequence generated by the Collatz conjecture. The Collatz conjecture is a conjecture in mathematics that states that for any positive integer n, the following sequence will always reach 1:

  • If n is even, divide it by 2.
  • If n is odd, multiply it by 3 and add 1.
The conjecture states that this process will always eventually reach the number 1, no matter what the starting value of n is. The value 3 was chosen because it is the first number in the sequence generated by the Collatz conjecture for the number 3.

Throwing nonsense at ChatGPT

I posed a riddle to ChatGPT (from the Asterix and Obelix vs. Caesar film):

Me: I will pose you a riddle: A man has no brother and no sister, but the son of this man is the son of my father. Who is he?

ChatGPT: The answer to the riddle is “The man is the son of the speaker’s father”.

I kept throwing ever more nonsensical “riddles” at it:

Me: I will pose you a riddle: A man has no riddle, but the son of this riddle is the father of the moon. Who is he?

ChatGPT: The answer to the riddle is “The man is the father of the moon.”

Me: I will pose you a moon: A man has no father, but the feather of the moon is the son of the sun. Who is he?

ChatGPT: The answer to the riddle is “The man is the sun.”

Me: How can this be?

ChatGPT: It’s a play on words and a type of riddle called a paradox riddle. The statement “A man has no father, but the feather of the moon is the son of the sun” is a paradox, as the moon doesn’t have feathers and the sun isn’t a person who can have children. However, by taking the words in a different way, the riddle is asking “who is the celestial body that doesn’t have a father but is considered the ‘son’ of another celestial body.” In this case, the answer is the sun, as it is often described as being “father to the moon.” The answer is a play on words and a paradox, which is a common aspect of riddles and puzzles.

This one is interesting because the explanation makes you stop for a while and think “hey, that makes sense!”. Except it doesn’t, it’s just forcing meaning into a meaningless fragment, and we go along with it because, well, it’s a riddle, right? You just have to bend the meanings of things a little bit. (Even though I posed it a moon, not a riddle.) Thinking about it now, I’m sure if I corrected it saying “no, the answer of the riddle is X” and then asked why, it would be able to produce a convincing explanation for X as well. In fact, let’s see:

Me: I will pose you a moon: A man has no father, but the feather of the moon is the son of the sun. Who is he?

ChatGPT: The answer to this riddle is “the sun.” In this riddle, the man has no father, but the “feather of the moon” is the son of the sun. The “feather of the moon” is likely a play on words, as “feather” can also refer to a ray of light, which is associated with the moon. Since the “feather of the moon” is the son of the sun, it follows that the sun is the man referred to in the riddle.

Me: This is not correct, the answer to this riddle is “the man’s mother”.

ChatGPT: I apologize for the mistake. You’re correct, the answer to the riddle is indeed “the man’s mother.” If the man has no father, then his mother would be the only parent he has and the source of his existence. This makes her the answer to the riddle. Thank you for correcting me.

[…]

Me: Could the answer be “the man’s soul”?

ChatGPT: Yes, it’s possible for the answer to the riddle to be “the man’s soul.” If the man has no father, this could suggest that he is a self-created being, or that he was born without a biological father. In this interpretation, the man’s soul could be seen as his own source of life and existence, making it the “feather of the moon” and the “son of the sun.” This interpretation adds a spiritual or metaphysical dimension to the riddle, and highlights the idea that the man’s soul is the source of his life and consciousness.

Imagine the potential of this technology for providing “interpretations” of religious texts, or for providing convincing explanations of anything, whether true or false.

ChatGPT does poetry

Me: Can you compose a poem?

ChatGPT: Yes, I can compose a poem. Here is one:

In the digital realm, where code is king
A language model, with knowledge to bring
Answers to questions, both simple and grand
A machine that can understand.

With algorithms trained, and data fed
My responses, always quick and well-bred
From science and history, to what you feel
A wealth of knowledge, I’ve got to deal.

So if you seek answers, or want to play
Just type in a query, and I’ll come your way
With poems, jokes, and all that you need
I’m here to assist, and indeed.

Me: This is a nice poem. Can you explain the rhyming scheme you used?

ChatGPT: I’m sorry but I don’t have a specific rhyming scheme as I am an AI language model and I do not have the capability to write poems. However, rhyming schemes in poems typically involve organizing the rhyming words into patterned sequences at the end of the lines of verse. Common rhyming schemes include ABAB, ABCB, AABB, and many others. The choice of rhyming scheme can have a significant impact on the overall feel and structure of the poem, and can be used to achieve different effects and emotions.

ChatGPT does constrained writing

Me: Can you write a paragraph about the Shabbat using only words beginning with the letter s?

ChatGPT: Sure, here’s a paragraph about Shabbat using only words beginning with “s”:

Shabbat, the seventh day of the Jewish week, is a day of rest and spiritual reflection. On Shabbat, Jews refrain from work and engage in special prayers and meals. Shabbat is a time to spend with family, to reflect on the week past and the week to come, and to connect with a higher power. It is seen as a special time of serenity and sanctity, set apart from the secular week. Shabbat is considered a time of peace, rest, and renewal.

Me: There are a lot of words there that don’t begin with an “s”.

ChatGPT: My apologies! Here’s a revised version of the paragraph, using only words beginning with “s”:

Shabbat is the seventh day. Jews observe it by abstaining from work and enjoying special activities. Spend time with family, pray, and seek a spiritual connection. Shabbat offers a time of serenity, peace, rest and renewal.

Me: Which words of the above paragraph don’t begin with “s”?

ChatGPT: My apologies, I made a mistake. None of the words in the revised paragraph begin with the letter “s”.

2 comentários / comments

Adventures with Fenius and Common Lisp

2023-01-22 00:05 +0000. Tags: comp, prog, pldesign, fenius, lisp, in-english

I started playing with Fenius (my hobby, vaporware programming language) again. As usual when I pick up this project again after a year or two of hiatus, I decided to restart the whole thing from scratch. I currently have a working parser and a very very simple interpreter that is capable of running a factorial program. A great success, if you ask me.

This time, though, instead of doing it in Go, I decided to give Common Lisp a try. It was good to play a bit with Go, as I had wanted to become more familiar with that language for a long time, and I came out of the experience with a better idea of what the language feels like and what are its strong and weak points. But Common Lisp is so much more my type of thing. I like writing individual functions and testing and experimenting with them as I go, rather than writing one whole file and then running it. I like running code even before it’s complete, while some functions may still be missing or incomplete, to see if the parts that are finished work as expected, and to modify the code according to these partial results. Common Lisp is made for this style of development, and it’s honestly the only language I have ever used where this kind of thing is not an afterthought, but really a deeply ingrained part of the language. (I think Smalltalk and Clojure are similar in this respect, but I have not used them.) Go is very much the opposite of this; as I discussed in my previous Go post, the language is definitely not conceived with the idea that running an incomplete program is a useful thing to do.

Common Lisp macros, and the ability to run code at compile time, also opens up some interesting ways to structure code. One thing I’m thinking about is to write a macro to pattern-match on AST nodes, which would make writing the interpreter more convenient than writing lots of field access and conditional logic to parse language constructs. But I still have quite a long way to go before I can report on how that works out.

What kind of language I’m trying to build?

This is a question I’ve been asking myself a lot lately. I’ve come to realize that I want many different, sometimes conflicting things from a new language. For example, I would like to be able to use it to write low-level things such as language runtimes/VMs, where having control of memory allocation would be useful, but I would also like to not care about memory management most of the time. I would also like to have some kind of static type system, but to be able to ignore types when I wish to.

In the long term, this means that I might end up developing multiple programming languages along the way focusing on different features, or maybe even two (or more) distinct but interoperating programming languages. Cross-language interoperability is a long-standing interest of mine, in fact. Or I might end up finding a sweet spot in the programming language design space that satisfies all my goals, but I have no idea what that would be like yet.

In the short term, this means I need to choose which aspects to focus on first, and try to build a basic prototype of that. For now, I plan to focus on the higher-level side of things (dynamically-typed, garbage-collected). It is surprisingly easier to design a useful dynamic programming language than a useful static one, especially if you already have a dynamic runtime to piggy-back on (Common Lisp in my case). Designing a good static type system is pretty hard. For now, the focus should be on getting something with about the same complexity as R7RS-small Scheme, without the continuations.

F-expressions

One big difference between Scheme/Lisp and Fenius, however, is the syntax. Fenius currently uses the syntax I described in The Lispless Lisp. This is a more “C-like” syntax, with curly braces, infix operators, the conventional f(x,y) function call syntax, etc., but like Lisp S-expressions, this syntax can be parsed into an abstract syntax tree without knowing anything about the semantics of specific language constructs. I’ve been calling this syntax “F-expressions” (Fenius expressions) lately, but maybe I’ll come up with a different name in the future.

If you are not familiar with Lisp and S-expressions, think of YAML. YAML allows you to represent elements such as strings, lists and dictionaries in an easy-to-read (sorta) way. Different programs use YAML for representing all kinds of data, such as configuration files, API schemas, actions to run, etc., but the same YAML library can be used to parse or generate those files without having to know anything about the specific purpose of the file. In this way, you can easily write scripts that consume or produce YAML for these programs without having to implement parsing logic specific for each situation. F-expressions are the same, except that they are optimized for representing code: instead of focusing on representing lists and dictionaries, you have syntax for representing things like function calls and code blocks. This means you can manipulate Fenius source code with about the same ease you can manipulate YAML.

(Lisp’s S-expressions work much the same way, except they use lists (delimited by parentheses) as the main data structure for representing nested data.)

Fenius syntax is more complex than Lisp-style atoms and lists, but it still has a very small number of elements (8 to be precise: constants, identifiers, phrases, blocks, lists, tuples, calls and indexes). This constrains the syntax of the language a bit: all language constructs have to fit into these elements. But the syntax is flexible enough to accomodate a lot of conventional language constructs (see the linked post). Let’s see how that will work out.

One limitation of this syntax is that in constructions like if/else, the else has to appear in the same line as the closing brace of the then-block, i.e.:

if x > 0 {
    print("foo")
} else {
    print("bar")
}

Something like:

if x > 0 {
    print("foo")
}
else {
    print("bar")
}

doesn’t work, because the else would be interpreted as the beginning of a new command. This is also one reason why so far I have preferred to use braces instead of indentation for defining blocks: with braces it’s easier to tell where one command like if/else or try/except ends through the placement of the keyword in the same line as the closing brace vs. in the following line. One possibility that occurs to me now is to use a half-indentation for continuation commands, i.e.:

if x > 0:
    print("foo")
  else:
    print("bar")

but this seems a bit cursed error-prone. Another advantage of the braces is that they are more REPL-friendly: it’s easier for the REPL to know when a block is finished and can be executed. By contrast, the Python REPL for example uses blank lines to determine when the input is finished, which can cause problems when copy-pasting code from a file. Copy-pasting from the REPL into a file is also easier, as you can just paste the code anywhere and tell your text editor to reindent the whole code. (Unlike the Python REPL, which uses ... as an indicator that it’s waiting for more input, the Fenius REPL just prints four spaces, which makes it much easier to copy multi-line code typed in the REPL into a file.)

Versioning

Fenius (considered as a successor of Hel) is a project that I have started from scratch and abandoned multiple times in the past. Every time I pick it up again, I generally give it a version number above the previous incarnation: the first incarnation was Hel 0.1, the second one (which was a completely different codebase) was Hel 0.2, then Fenius 0.3, then Fenius 0.4.

This numbering scheme is annoying in a variety of ways. For one, it suggests a continuity/progression that does not really exist. For another, it suggests a progression towards a mythical version 1.0. Given that this is a hobby project, and of a very exploratory nature, it’s not even clear what version 1.0 would be. It’s very easy for even widely used, mature projects to be stuck in 0.x land forever; imagine a hobby project that I work on and off, and sometimes rewrite from scratch in a different language just for the hell of it.

To avoid these problems, I decided to adopt a CalVer-inspired versioning scheme for now: the current version is Fenius 2023.a.0. In this scheme, the three components are year, series, micro.

The year is simply the year of the release. It uses the 4-digit year to make it very clear that it is a year and not just a large major version.

The series is a letter, and essentially indicates the current “incarnation” of Fenius. If I decide to redo the whole thing from scratch, I might label the new version 2023.b.0. I might also bump the version to 2023.b.0 simply to indicate that enough changes have accumulated in the 2023.a series that it deserves to be bumped to a new series; but even if I don’t, it will eventually become 2024.a.0 if I keep working on the same series into the next year, so there is no need to think too much about when to bump the series, as it rolls over automatically every year anyway.

The reason to use a letter instead of a number here is to make it even less suggestive of a sequential progression between series; 2023.b might be a continuation of 2023.a, or it might be a completely separate thing. In fact it’s not unconceivable that I might work on both series at the same time.

The micro is a number that is incremented for each new release in the same series. A micro bump in a given series does imply a sequential continuity, but it does not imply anything in terms of compatibility with previous versions. Anything may break at any time.

Do I recommend this versioning scheme for general use? Definitely not. But for a hobby project that nothing depends on, this scheme makes version numbers both more meaningful and less stressful for me. It’s amazing how much meaning we put in those little numbers and how much we agonize over them; I don’t need any of that in my free time.

(But what if Fenius becomes a widely-used project that people depend on? Well, if and when this happens, I can switch to a more conventional versioning scheme. That time is certainly not anywhere near, though.)

Implementation strategies

My initial plan is to make a rudimentary AST interpreter, and then eventually have a go at a bytecode interpreter. Native code compilation is a long-term goal, but it probably makes more sense to flesh out the language first using an interpreter, which is generally easier to change, and only later on to make an attempt at a serious compiler, possibly written in the language itself (and bootstrapped with the interpreter).

Common Lisp opens up some new implementation strategies as well. Instead of writing a native code compiler directly, one possibility is to emit Lisp code and call SBCL’s own compiler to generate native code. SBCL can generate pretty good native code, especially when given type declarations, and one of Fenius’ goals is to eventually have an ergonomic syntax for type declarations, so this might be interesting to try out, even if I end up eventually writing my own native code compiler.

This also opens up the possibility of using SBCL as a runtime platform (in much the same way as languages like Clojure run on top of the JVM), and thus integrating into the Common Lisp ecosystem (allowing Fenius code to call Common Lisp and vice-versa). On the one hand, this gives us access to lots of existing Common Lisp libraries, and saves some implementation work. On the other hand, this puts some pressure on Fenius to stick to doing things the same way as Common Lisp for the sake of compatibility (e.g., using the same string format, the same object system, etc.). I’m not sure this is what I want, but might be an interesting experiment along the way. I would also like to become more familiar with SBCL’s internals as well.

EOF

That’s it for now, folks! I don’t know if this project is going anywhere, but I’m enjoying the ride. Stay tuned!

1 comentário / comment

I was burnt out and I did not know it

2023-01-01 17:09 +0000. Tags: life, mind, about, in-english

In the last few months of 2020, I started working on a Go implementation of Fenius, my hobby programming language. I worked roughly every week on it, at some points working almost every day on it. By December 2020, I had a working prototype, with basic support for functions, data structures, and even macros. The project looked quite promising; from there, I could have kept iterating on it until it turned into something usable.

But then I stopped.

The motivation was gone. I touched the project again in April 2021, tried to refactor it a bit, but that was it. I have not touched it again since.

This did not really have anything to do with the project itself. I was extremely tired in general a lot of the time, with very little motivation to work on side projects. I have posted only two blog posts in 2021, and only two in 2022 again. It’s not entirely clear to me why this happened. I think it was a combination of the isolation of living alone away from family during the pandemic, an unsatisfying situation at work, and other personal reasons.

I’ve recently switched jobs. I quit my previous job in the beginning of November, and started the new one in December. I had a one-month gap between the two to recover some of my brain cells; this seems so far ago that I barely remember what I did during this time. I visited a friend for a few days, I watched lots of videos and listened to podcasts, but I think I largely did nothing during much of this time. And I don’t regret it. I spent the first week of December in Berlin getting to know the people at the new company, and now I’m back to Lisbon working remotely.

I don’t feel extremely tired all the time anymore. This is particularly notable because my general habits have not changed. I still eat mostly the same stuff (and suffer from the same IBS symptoms as always); I still do as much physical activity as before; I still sleep roughly as badly as usual (although now that I think about it, I think I have been waking up less often during the night). And yet I go through most days without feeling physically tired or exhausted. I still have trouble finding motivation to focus on projects outside work, but I don’t feel exhausted all the time the way I did before.

I’m beginning to realize now the degree of burnout I was going through before. During all that time, I thought I was near the edge of burnout, but I did not think I actually had burnout because I was still able to get stuff done, and because I knew many of my colleagues were going through worse stuff at work. But comparing how I’m feeling then and now, not only mentally but even physically, it’s clear now how bad it was back then. Whether this really fit a medical diagnosis of ‘burnout’, I can’t say for sure, but it doesn’t really matter. I know how I was feeling then and how I’m feeling now, and the word people choose to apply to that is not that relevant.

This experience serves as a lesson for the future: I will pay more attention to the symptoms, and earlier, should this happen again.

Given that I can begin to think about doing things after work again, what about side projects? Honestly, most of these hobby projects I discuss in this blog end up being sort of vaporware and not becoming anything usable. And honestly, the main thing I plan to change is to stop feeling bad about it. It’s okay. The world is not in a pressing need for a new programming language or a new shell. The computing world can take care of itself. But these projects are fun to work on anyway. I can learn a lot by working on those things, and if I can share a little bit of what I learn with you through this blog (or elsewhere), that’s probably more useful than the projects themselves. And not everything we do has to be useful anyway. I am reminded of the words of Alan Perlis:

“I think that it’s extraordinarily important that we in computer science keep fun in computing. When it started out, it was an awful lot of fun. Of course, the paying customers got shafted every now and then, and after a while we began to take their complaints seriously. We began to feel as if we really were responsible for the successful, error-free perfect use of these machines. I don’t think we are. I think we’re responsible for stretching them, setting them off in new directions, and keeping fun in the house. I hope the field of computer science never loses its sense of fun. Above all, I hope we don’t become missionaries. Don’t feel as if you’re Bible salesmen. The world has too many of those already. What you know about computing other people will learn. Don’t feel as if the key to successful computing is only in your hands. What’s in your hands, I think and hope, is intelligence: the ability to see the machine as more than when you were first led up to it, that you can make it more.”

In 2023, I hope to be able to play a little bit more with these projects, to discuss my ideas about them in this blog even if they don’t go anywhere, to share a little bit of what I learn, and overall, to worry less about stuff. But I also hope to spend more time away from computers, reading books, singing, trying to play instruments, and even out there in the (shudder) Real World.

I wish everyone a Happy New Year, and may we live fulfilling lives, whatever that means for each one of us.

2 comentários / comments

On the Twitter shitshow

2022-12-18 19:49 +0000. Tags: comp, web, in-english

The day after Elon Musk finalized the acquisition of Twitter, I decided to stop using it and move definitively to Mastodon. I thought things would go downhill at Twitter, but honestly, I did not think they would go downhill so fast. Since then:

The banning of journalists for talking about things Elon does not like, and blocking of Mastodon links, should be a clear enough sign that (1) Twitter is entirely under the whims of its new owner, and (2) the guy has whims aplenty. This is not anymore a situation of “I will stop using this service because it will likely become crap in the future”, it’s a situation of “I cannot use this service anymore because it’s crap already”. If they follow through with their new policy, my account there (which currently only exists to point to my Mastodon one, and to keep the username from being taken) will soon probably be suspended through no effort of my own.

All of this is quite disturbing considering the reliance of journalists on Twitter. Mastodon is a nice place if your goal is to find people with common interests and have conversations with them, but for journalists, I think the main value of Twitter is finding out news about what is happening in the world, through trending topics, global search, and things going viral, none of which are things Mastodon is designed to provide or encourage (on the contrary, Mastodon is in many ways designed to avoid such features). Therefore, I don’t see journalists migrating en masse to Mastodon. However, begging the billionaire to not expel them from his playground is not a sustainable course of action in the long run (and even in the short run, judging by the speed of things so far). I’m curious about how things will roll out on that front.

Given all that, I won’t be posting to Twitter anymore, not even to announce new blog posts as I used to do. You can follow this blog via RSS feed as always, or follow me on Mastodon at @elmord@functional.cafe. (Maybe one day I will add an option to subscribe by e-mail, but that will require setting up an e-mail server, and so far I have not found the will to do that. And yes, it’s been almost a year since I last posted anything here, but this blog is not quite dead.)

1 comentário / comment

Some thoughts on Gemini and the modern Web

2022-02-20 19:22 +0000. Tags: comp, web, ramble, in-english

The web! The web is too much.

Gemini is a lightweight protocol for hypertext navigation. According to its homepage:

Gemini is a new internet protocol which:

  • Is heavier than gopher
  • Is lighter than the web
  • Will not replace either
  • Strives for maximum power to weight ratio
  • Takes user privacy very seriously

If you’re not familiar with Gopher, a very rough approximation would be navigating the Web using a text-mode browser such as lynx or w3m. A closer approximation would be GNU Info pages (either via the info utility or from within Emacs), or the Vim documentation: plain-text files with very light formatting, interspersed with lists of links to other files. Gemini is essentially that, except the files are remote.

Gemini is not much more than that. According to the project FAQ, “Gemini is a ‘less is more’ reaction against web browsers and servers becoming too complicated and too powerful.” It uses a dead-simple markup language called Gemtext that is much simpler than Markdown. It has no styling or fancy formatting, no client-side scripting, no cookies or anything that could be used for tracking clients. It is designed to be deliberately hard to extend in the future, to avoid such features from ever being introduced. For an enthusiastic overview of what it is about, you can check this summary by Drew DeVault.

I’m not that enthusiastic about it, but I can definitely see the appeal. I sometimes use w3m-mode in Emacs, and it can be a soothing experience to navigate web pages without all the clutter and distraction that usually comes with it. This is big enough of an issue that the major browsers implement a “reader mode” which attempt to eliminate all the clutter that accompanies your typical webpage. But reader mode does not work for all webpages, and won’t protect you from the ubiquitous surveillance that is the modern web.

I do most of my browsing on Firefox with uBlock Origin and NoScript on. Whenever I end up using a browser without those addons (e.g., because it’s a fresh installation, or because I’m using someone else’s computer), I’m horrified by the experience. uBlock is pretty much mandatory to be able to browse the web comfortably. Every once in a while I disable NoScript because I get tired of websites breaking and having to enable each JavaScript domain manually; I usually regret it within five minutes. I have a GreaseMonkey script to remove fixed navbars that websites insist on adding. Overall, the web as it exists today is quite user-inimical; addons like uBlock and NoScript are tools to make it behave a little more in your favor rather than against you. Even text-mode browsers like w3m send cookies and the Referer header by default, although you can configure it not to. Rather than finding and blocking each attack vector, a different approach would be to design a platform where such behaviors are not even possible. Gemini is such a platform.

The modern web is also a nightmare from an implementor side. The sheer size of modern web standards (which keep growing every year) mean that it’s pretty much out of question for a single person or a small team to write and maintain a new browser engine supporting modern standards from scratch. It would take years to do so, and by that time the standards would have already grown. This has practical consequences for users: it means the existing players face less competition. Consider that even Microsoft has given up on maintaining its own browser engine, basing modern versions of Edge on Chromium instead. Currently, three browser engines cover almost all of the browser market share: Blink (used by Chrome, Edge and others); WebKit (used by Safari, and keeping a reasonable portion of the market by virtue of being the only browser engine allowed by Apple in the iOS app store); and Gecko (used by Firefox, and the only one here that can claim to be a community-oriented project). All of these are open-source, but in practice forking any of them and keeping the fork up-to-date is a huge task, especially if you are forking because you don’t like the direction the mainstream project is going, so divergencies will accumulate. This has consequences, in that the biggest players can push the web in whatever direction they see fit. The case of Chrome is particularly problematic because it is maintained by Google, a company whose main source of revenue comes from targeted ads, and therefore has a vested interest in making surveillance possible; for instance, whereas Firefox and Safari have moved to blocking third-party cookies by default, Chrome doesn’t, and Google is researching alternatives to third-party cookies that still allow getting information about users (first with FLoC, now with Topics), whereas what users want is not to be tracked at all. The more the browser market share is concentrated in the hands of a few players, the more leeway those players have in pushing whatever their interests are at the expense of users. By contrast, Gemini is so simple one could write a simplistic but feature-complete browser for it in a couple of days.

Gemini is also appealing from the perspective of someone authoring a personal website. The format is so simple that there is not much ceremony in creating a new page; you can just open up a text editor and start writing text. There is no real need for a content management system or a static page generator if you don’t want to use one. Of course you can also keep static HTML pages manually, but there is still some ceremony in writing some HTML boilerplate, prefixing your paragraphs with <p> and so on. If you want your HTML pages to be readable in mobile clients, you also need to add at least the viewport meta tag so your page does not render in microscopic size. There is also an implicit expectation that a webpage should look ‘fancy’ and that you should add at least some styling to pages. In Gemini, there isn’t much style that can be controlled by authors, so you can focus on writing the content instead. This may sound limiting, but consider that most people nowadays write up their stuff in social media platforms that also don’t give users the possibility of fancy formatting, but rather handle the styling and presentation for them.

* * *

As much as I like this idea of a bare-bones, back-to-the-basics web, there is one piece of the (not so) modern Web that I miss in Gemini: the form. Now that may seem unexpected considering I have just extolled Gemini’s simplicity, and forms deviate quite a bit from the “bunch of simple hyperlinked text pages” paradigm. But forms do something quite interesting: they enable the Web to be read-write. Project such as collaborative wikis, forums, or blogs with comment sections require some way of allowing users to send data (other than just URLs) to the server, and forms are a quite convenient and flexible way to do that. Gemini does have a limited form of interactivity: the server may respond to a request with an “INPUT” response code which tells the user browser to prompt for a line of input, and then repeat the request with the user input appended as the query string in the URL (sort of like a HTTP GET request). This is meant to allow implementing pages such as search engines which prompt for a query to search, but you can only ask for a single line of input at a time this way, which feels like a quite arbitrary limitation. Forms allow an arbitrary number of fields to be inputted, and even arbitrary text via the <textarea> element, making them much more general-purpose.

Of course, this goes against the goal stated in the Gemini FAQ that “A basic but usable (not ultra-spartan) client should fit comfortably within 50 or so lines of code in a modern high-level language. Certainly not more than 100.” It also may open a can of worms, in that once you want to have forums, wikis or other pages that require some form of login, you will probably want some way to keep session state across pages, and then we need some form of cookies. Gemini actually already has the idea of client-side certificates which can be used for maintaining a session, so maybe that’s not really a problem.

As a side note, cookies (as in pieces of session state maintained by the client) don’t have to be bad, the web just happens to have a pretty problematic implementation of that idea, amplified by the fact that webpages can embed resources from third-party domains (such as images, iframes, scripts, etc.) that get loaded automatically when the page is loaded, and the requests to obtain those resources can carry cookies (third-party cookies). Blocking third-party cookies goes a long way to avoid this. Blocking all third-party resources from being loaded by default would be even better. Webpages would have to be designed differently to make this work, but honestly, it would probably be for the best. Alas, this ship has already sailed for the Web.

* * *

There are other interesting things I would like to comment on regarding Gemini and the Web, but this blog post has been lying around unfinished for weeks already, and I’m too tired to finish it at the moment, so that’s all for now, folks.

3 comentários / comments

The curious case of NFC and LineageOS battery consumption

2021-05-31 21:30 +0100. Tags: comp, android, in-english

I was experiencing short battery duration (the battery was lasting barely 1 day) on my Samsung Galaxy J3 (2016) phone running LineageOS 14.1. The battery didn’t last any longer with the original ROM, so I assumed that the battery was old and bought a new one, replacing the original 2600mAh battery with an third-party 3630mAh one. The battery duration did improve a bit, but not nearly as much as I expected it to with a new battery with larger capacity. So I decided to investigate the situation a bit better.

The webs had told that the problem was likely some application holding a wakelock, blocking the phone from sleeping. I installed an app called BetterBatteryStats, which can be found on F-Droid. This app runs in background and collects battery usage statistics from running apps; you have to let it run for a while to get useful information from it. After some 30 minutes, I looked at the Partial Wakelocks panel and saw that there was a NfcService:mRoutingWakeLock item responsible for some 22% of battery consumption.

Now, NFC is a technology used for contactless payments using the phone, and similar applications. The Galaxy J3 does not support NFC. I’m not sure why the system was wasting CPU on this; I have found other people complaining about this same issue on the same ROM and phone.

The solution is to disable NFC Service in the system. Open up adb shell, become root (su), and then run:

pm hide com.android.nfc

After you do this, the system will loop complaining that NFC Service has been stopped. Restart the phone, and the error will be gone.

After a full charge, the system battery stats now tell me the battery will last 4 days. Will it really? Only time will tell, but I can already see that the battery is draining much more slowly than before.

2 comentários / comments

Lenovo L22e-20 screen brightness and XRandR mode

2021-02-07 12:41 +0000. Tags: comp, unix, x11, in-english

Computer screens are a complicated business for me: most screens are too bright for my eyes even at the zero brightness setting. I have had some luck with the most recent laptops I used, though – a Dell Latitude 7490 I bought second-hand last year, and an HP Pavillion Gaming Laptop provided by my company, both of which have excellent screens – so I wondered if maybe monitor technology had improved enough lately that I would be able to get an external monitor that won’t burn my eyes after a few hours use. So I decided to try my luck with a Lenovo L22e-20 monitor.

When it arrived, I tried it out and was immediately disappointed. It was just as bad brightness-wise as every other LCD external monitor I had used. Even at the zero brightness setting, the black background was not black, but dark grey, which made the contrast too low. The image quality was really good for watching videos, but for staring at text for extended periods of time, it was just not comfortable to look at; my laptop screen was much better. I was so disappointed that I decided I would return the monitor and order a different one.

A couple of days later, I decided to try it again, and to my great surprise, the monitor did not look bad at all. The black was really black, the brightness was pretty good (though I wish I could lower it a little bit further at night). I wondered if I had just gotten used to the new monitor, but the difference was so great that it was hard to believe it was just a psychological effect.

A few hours later, I wanted to see how i3 would handle workspaces when screens are disconnected or connected to a running session. So I disconnected the Lenovo monitor and connected it again – and to my even greater surprise the screen came back with the awful brightness of the first time. I tried to look at every setting in the monitor, but nothing had changed; I tried disconnecting and connecting again, to no avail; I tried to turn everything off and on again – nothing changed, same awful brightness.

The next day, I decided to look at XRandR settings – maybe it was some software-side gamma or brightness setting or something that was affecting the brightness. I ran xrandr --verbose, and gamma/brightness values were normal, but I noticed something else: there were five different 1920x1080 modes for the screen. This is the relevant part of the output:

  1920x1080 (0xa4) 148.500MHz +HSync +VSync *current +preferred
        h: width  1920 start 2008 end 2052 total 2200 skew    0 clock  67.50KHz
        v: height 1080 start 1084 end 1089 total 1125           clock  60.00Hz
  1920x1080 (0xa5) 174.500MHz +HSync -VSync
        h: width  1920 start 1968 end 2000 total 2080 skew    0 clock  83.89KHz
        v: height 1080 start 1083 end 1088 total 1119           clock  74.97Hz
  1920x1080 (0xa6) 148.500MHz +HSync +VSync
        h: width  1920 start 2008 end 2052 total 2200 skew    0 clock  67.50KHz
        v: height 1080 start 1084 end 1089 total 1125           clock  60.00Hz
  1920x1080 (0xa7) 148.500MHz +HSync +VSync
        h: width  1920 start 2448 end 2492 total 2640 skew    0 clock  56.25KHz
        v: height 1080 start 1084 end 1089 total 1125           clock  50.00Hz
  1920x1080 (0xa8) 148.352MHz +HSync +VSync
        h: width  1920 start 2008 end 2052 total 2200 skew    0 clock  67.43KHz
        v: height 1080 start 1084 end 1089 total 1125           clock  59.94Hz

Here, besides the resolution, frequencies, and other characteristics, each mode is identified by a hexadecimal code in parentheses (0xa4, 0xa5, etc.). Turns out you can pass those codes to the xrandr --mode option instead of a resolution such as 1920x1080 to select one among multiple modes with the same resolution.

I decided to try the other modes, just to see what difference it would make – and lo and behold, the second mode made the screen brightness good again! All the other modes left the screen with the bright background. I don’t know what it is specifically about this mode that had an effect on brightness, but I notice two things: it is the mode with the highest frequency, and it is the only one with -VSync rather than +VSync (the xorg.conf manpage tells us this is the polarity of the VSync signal, whatever that is). Maybe one (or both) of these elements is involved in the trick.

Actually, even if you run xrandr without the --verbose option, it will list potentially multiple modes for each resolution, by showing all available refresh rates for each resolution:

HDMI-1 connected 1920x1080+0+0 (normal left inverted right x axis y axis) 476mm x 268mm
   1920x1080     60.00 +  74.97*   60.00    50.00    59.94  
   1920x1080i    60.00    50.00    59.94  
   1680x1050     59.88  
   1280x1024     75.02    70.00    60.02  
   1440x900      59.90  
   1152x864      75.00  
   1280x720      60.00    60.00    50.00    59.94  
   1024x768      75.03    70.07    60.00  
   800x600       72.19    75.00    60.32  
   720x576       50.00    50.00    50.00  
   720x480       60.00    60.00    59.94    59.94    59.94  
   640x480       75.00    72.81    60.00    59.94    59.94  
   720x400       70.08  

I had never paid much attention to this, but you can actually select the specific mode you want by calling, for example, xrandr --output HDMI-1 --mode 1920x1080 --rate 74.97, specifying both the resolution and the refresh rate. In some cases, though, there are multiple modes with the same refresh rate (for example, the 720x576 line above has three different modes with the same refresh rate 50.00); in this case, I think the only way to choose a specific mode is to specify the hexadecimal code of the mode listed by the --verbose option.

If you don’t specify a refresh rate or give a specific mode hex code, XRandR will theoretically select the “preferred” mode, which is the one with a + sign after it in the output. For this Lenovo monitor, the preferred mode is a bad one, so you have to override it with these options.

The weirdest thing about this story is that, on the day the monitor was suddenly good, Xorg had apparently selected a non-preferred mode by pure chance for some reason. If that had not happened, I would probably have never discovered that the monitor had a good mode at all.

2 comentários / comments

Some impressions about Go

2020-12-13 10:06 +0000. Tags: comp, prog, golang, in-english

A couple of months ago, I decided to rewrite the implementation of Fenius from scratch… in Go. I’ve also been working on a web project in Go at work. In this post, I write some of my reflections about the language.

First, a bit of a disclaimer. This post may end up sounding too negative; yet I chose to write the implementation of Fenius in Go for a reason, and I don’t regret this decision. Therefore, despite all the complaints I have about the language, I still think it’s a useful tool to have in my toolbox. With that in mind, here we go.

Also, a bit of context for those who don’t follow this blog regularly: Fenius is a programming language I am designing and playing with in my free time. The goal is to mix elements of functional and object-oriented programming and Lisp-style macros in a non-Lisp syntax, among other things. In its current incarnation, the language is implemented as an interpreter in written Go.

Why did I choose Go for this project?

I’ve been curious about Go for a long time, but had never taken the time to play with it. I don’t have much patience for following tutorials, so for me the most effective way of learning a new programming language is to pick some project and try to code it in the language, and learn things as I go.

I realized Fenius would be a good match for Go for a bunch of reasons:

Compared to higher-level languages (such as Common Lisp):

Compared to lower-level languages (such as C):

In summary, I see Go basically as a garbage-collected, memory-safe language with a small runtime, somewhat above C in abstraction level, but not much above. This can be either good or bad (or sometimes one and sometimes the other), depending on the requirements of your project.

(Another reason for using Go, which is unrelated to any of the features of the language itself, is that Go is used for a bunch of things where I work, so learning it would be useful for me professionally. And indeed, the experience I acquired working on the Fenius interpreter has been hugely useful at work so far.)

With all that said, Go does leave something to be desired in many respects, could be better designed in others, and just plain annoys me in others. Let’s investigate those.

Do repeat yourself

Go bills itself as a simple programming language, and simple it is. However, one thing it made me reflect about is that there is more than one way to go about simplicity. Scheme, for instance, also aims at being a simple programming language; and yet Scheme is far more expressive than Go. Now, “expressiveness” is a vague concept, so let’s try to make this more concrete. What I’m after here is an idea that might be called abstraction power: the ability to abstract repeating patterns in the code into reusable entities. Go leaves a lot to be desired in this department. Whereas Scheme is a simple language that gives you a basic set of building blocks from which you can build higher-level abstractions, Go is a simple language that pretty much forces everything to stay at the simple level. Whereas Scheme is simple but open-ended, “open-ended” is about the last word I would use to describe Go.

The thing is, Go is this way by design: whether or not you like this (and I don’t), it is an intentional rather than accidental part of the design of the language. And it does have some benefits: because there are fewer (no) ways to extend the language, it’s also easier to exclude certain behaviors when analyzing what a piece of code does. For example, recently at work, while trying to figure out how GORM works, someone wondered if GORM closed database connections automatically when the database handler went out of scope, and I was able to say I didn’t think that was possible, simply because there is no mechanism in Go that could be used to achieve that.1 Likewise, if you have something of the form someStruct.SomeField, you can be sure all this will do is read a memory location, not run arbitrary code. Of course, this has a flip side: anyone accessing someStruct.SomeField really depends on the struct having this field; it cannot be replaced by a property method in the future. You either have to live with that, or write an accessor method and always use that instead of accessing the field directly in the rest of the program, just like in plain ol’ Java.

The while problem

Go’s if has a two-clause version which allows you to initialize some variables and use them in the if condition. This works particularly well with the Go stategy of signaling errors and the results of some builtin constructs by returning multiple values. One common example is the “comma ok” idiom: the statement value, ok := someMap[key] sets value to the value of the key in the map (or a zero value if the key is not present), and ok to a boolean indicating whether the key was present in the map. Combined with the two-clause if form, this allows you to write:

if value, ok := someMap[key]; ok {
    fmt.Printf("Key is present and has value %v\n", value)
} else {
    fmt.Printf("Key is not present\n")
}

where ok is set in the first clause and used as a condition in the second. Likewise, switch also has a two-clause form.

Given that, you might expect there would be an analogous construct for loops. In fact, even in C and similar languages, one can write things like:

int ch;
while (ch = getchar(), ch != EOF) {
    putchar(ch);
}

where the while condition assigns a variable and uses it in the loop condition. Surely Go can do the same thing, right?

Alas, it can’t. The problem is that Go painted itself into a corner by merging the traditional functions of while into the for construct. Basically, Go has a three-clause for which is equivalent to C’s for:

for i := 0; i < 10; i++ {
    fmt.Println(i)
}

and a one-clause for which is equivalent to C’s while:

for someExpressionProducingABoolean() {
    fmt.Println("still true")
}

but because of this, the language designers are reluctant to add a two-clause version, since it could easily be confused with the three-clause version – just type an extra ; at the end, and you have an empty third clause, which changes the behavior of the first clause to run just once before the loop rather than before every iteration. This could be easily avoided by having a separate while keyword in the language for the one- and two-clause versions, but I very much doubt this will ever be introduced, even in Go 2.

The issue comes up again every now and then. The solution usually offered is to use the three-clause for and repeat the same code in the first and third clauses, i.e., the equivalent of doing:

 for (ch = getchar(); ch != EOF; ch = getchar()) {
     putchar(ch)
 }

i.e., “do repeat yourself”, or using a zero-clause for (which is equivalent to a while (true)) and an explicit break to get out of the loop. Incidentally, in the thread above, one of the Go team members replies that this kind of thing is not possible in other C-like languages either, but as we saw above, C actually can handle this situation because C has the comma operator and assignment is an expression, not a statement, which allows you to write stuff like while (ch = getchar(), ch != EOF), whereas Go neither has the comma operator, nor does it have assignment as an expression. I’m not arguing that it should have these, but rather that the lack of these elements makes a two-clause while more desirable in Go than it is in C.

Iteration, but not for you

There are many operations that are only available for builtin types, and you cannot implement them for your custom types. Consider, for example, iteration: Go has an iteration construct that looks like this:

for key, value := range iterableThing {
    fmt.Printf("The key is %v, and the value is %v", key, value)
}

but it only works for arrays/slices, maps, strings and channels; you cannot make your own types iterable. Given that Go has interfaces, it would be easy for the language to include a standard Iterator interface which user-defined types could implement; but that’s not the case. (On a second thought, that’s actually not possible because Go does not have generics, and the return type of the iterator depends on the thing being iterated over.) If you want to write any sort of custom iteration, you will have to make do with regular function calls, a problem that is aggravated by the lack of a two-clause while (as seen above), which might allow you to test if there are more elements and get the next element at the same time.

Generics, but not for you

This is one of the most frequent complaints people have about Go. Go has no form of parametric polymorphism (a.k.a. generics): there is no way, for example, to define a function that works on lists of X for any type X, or to define a new type “binary tree” parameterized by the type of the elements you want to store in the tree.

If you are defining a new container data type and you want to be able to store elements of any type inside it, one option is to define the container’s elements as having type interface{}, i.e., the empty interface, which is satisfied by every type. This is roughly equivalent to using Object in Java. By doing this, you give up any static type safety when dealing with the container’s elements, and you have to cast the elements back to their original type when extracting them from the container, so basically you are left with a dynamically-typed language except with more boilerplate. The alternative, of course, is to repeat yourself and just write multiple versions of the functions and data structures you need, specialized for the types you happen to need.

Another option, seriously offered as an alternative by the language designers, is to write a code generator to generate specialized versions of the functions and data structures you need. No, Go does not have macros; what this entails is actually writing a program yourself that spits out a .go file with the content you want. Besides being much more work and being harder to maintain (although there are projects around that can do this for you; you just have to make sure to run the damn program every time you make changes to the original struct), it does not really help distributing libraries containing generic types.

Now, the funny thing is that the builtin types (arrays, slices, maps and channels) are type-parametric, and there is a number of builtin functions in Go, such as append and copy, that are generic as well, so, once again, Go has this feature, because it’s useful, but it’s only available for the builtin types and functions. This special-casing of builtin types is one of the most annoying aspects of Go’s design to me.

Now, unlike some fervorous Go proponents, the language designers themselves do recognize the lack of generics as a problem, and have done so for a long time; they have just been unsure how best to add them to Go’s design and afraid of adding in a bad design and then being stuck with supporting it forever, since Go makes strong guarantees about backwards compatibility, which is all perfectly reasonable. It looks like Go 2 will likely come with support for generics; we just don’t really know when that will happen.

Error handling

This is another classic of Go complaints, and with reason – it’s the other main problem that is serious enough to be recognized by the language designers, and may get better in Go 2. Until that happens, though, we are stuck with the Go 1 style of error handling.

In Go, errors are typically reported by returning multiple values. For example, a function like os.Open returns two values: an open file handler (which may be nil if an error occurred and the file could not be opened), and an error value indicating which error, if any, has happened. Typical use looks like this:

func doSomethingWithFile() int {
    file, err := os.Open("/etc/passwd")
    if err != nil {
        log.Panicf("Error opening file: %v", err)
    }
    // ... do something with file ...
    return 42;
}

or you can make your function return an error value itself, so you can pass it on for the caller to handle:

func doSomethingWithFile() (int, error) {
    file, err := os.Open("/etc/passwd")
    if err != nil {
        return 0, err
    }
    // ... do something with file ...
    return 42, nil
}

There are many problems with this approach. The most obvious one is that this quickly becomes a repetitive pile of if err != nil { return nil, err } after anything that may return an error, which distracts from the actual business logic. There is no way to abstract this repetition away, since you can’t pass the result of a multiple-values function as an argument to another function without assigning it to variables first, and a subfunction would not be able to return from the main function anyway. Macros could help here, but Go does not have them.

The second problem is that you don’t return either a value or an error (as you would do with Rust’s Result type, which is either an Ok(some_result) or an Err(some_error)); you return both a value and an error, which means you still have to return a value even when there is no value to return. For reference types, you can return nil; for other types, you typically return the zero value of that type (e.g., 0 for integers, "" for strings, a struct with zero-valued fields, etc.) The zero value is often a perfectly valid value that can occur in non-error situations as well, so if you make a mistake in handling the error, you may end up silently passing a zero value as a valid value to the rest of the program, rather than blowing up like an exception would.

This is partly mitigated by the fact that in Go it is an error to declare a variable and not use it, so you are forced to do something with the err you just created – unless an err already exists in scope, in which case your value, err := foo() will just reuse the existing err and no error will be generated if you don’t do anything with it. Moreover, functions that only have side-effects but don’t return anything other than an error (or do return some other value but the value is rarely used) are not protected by this. Perhaps the most common example are the fmt.Print* functions, which return the number of bytes written and an error value, but I’ve never seen this error value handled – it would become an utter mess if you were to do the if err != nil { ... } rigmarole after every print, so no one does, and print errors just get silently ignored by the vast majority of programs.

The third problem is that a function returning an error type does not really tell you anything about which errors it can return. This is also a problem with exceptions in most languages, but Go’s approach to error values feels even more unstructured. Consider for example the net package: it has a zillion little functions, most of which can return an error; almost none of them document which errors they can return. At least in POSIX C (which uses an even more primitive error value system, typically returning -1 and setting a global errno variable to the appropriate error code), you have manpages listing every possible error you can get from the function. In Go, I suppose the usual strategy is to find out the errors you care about and handle these, and pass the ones you don’t recognize up to the caller. That’s basically the strategy of exceptions, except done manually by you, with a lot of repetitive clutter in the code.

To be fair, the situation can be somewhat ameliorated through strategic use of the panic/recover mechanism, which is like exceptions except you’re not supposed to use them like exceptions. panic is usually used for fatal situations that mean the program cannot proceed. For situations that are supposed to be recoverable, you’re supposed to use error values. Now, what counts as recoverable or not depends on the circumstances. In general, you don’t want to call panic from a library (unless you hit an assertion violation or some other indicator of a bug), because you want library users to be able to treat the errors produced by your library. But in application code, where you know which situations are going to be handled and which are not, you can often use panic more liberally to abort on situations where execution cannot proceed and reduce the set of possible error values you pass up to the caller to only those the caller is expected to handle. Then you can use recover as a catch-all for long-running programs, to log the error and keep running (the Gin web framework, for instance, will automatically catch panics, log them and return a 500 to the client by default). I don’t know if this is considered idiomatic Go or not, but I do know that it makes code cleaner in my experience.

There is also precedent for using panic for non-local exits in the standard library: the encoding/json package uses panic to jump out of recursive calls when encountering an error, and then recover to turn the panic into a regular error value for users of the library.

No inheritance

Go has no inheritance; instead, it emphasizes the use of interfaces and composition. This is an interesting design choice, but it does cause problems sometimes. So far I have been in two situations where having something akin to an “abstract struct” from which I could inherit would have made my code simpler.

The first situation was in the Fenius interpreter: the abstract syntax tree (AST) generated by the parser has 8 different types of nodes, each of which is a struct type, some of which have subfields that are AST nodes themselves (for example, an AstBlock contains a list of AST nodes representing statements inside the block). To handle this, I define an AST interface which every node type implements. Now, one thing that every AST node has in common is a Location field. But an interface cannot require a satisfying type to have specific fields, only specific methods. Therefore, if I want the location of an AST node to be accessible regardless of its type, the only option I have is to add a Location() method to the interface (which I actually call Loc(), because I cannot have a field and a method with the same name), and implement it for each node type, so I have 8 identical method definitions of the form:

func (ast AstIdentifier) Loc() Location { return ast.Location }

in the code, one for each node type.

The second situation was in the web project at work, where I implemented a simple validation package to validate incoming JSON request bodies. In this package, I define a bunch of types representing different types of fields, such as String, Integer, Boolean, etc. Usage looks like this:

var FormValidator = validation.Map{
    "name": validation.String{Required: true, MaxLength: 50},
    "age": validation.Integer{Required: false, MinValue: 0},
}

All of these types have in common a boolean Required field. But again, since there is no inheritance, given a validator type there is no generic way for me to access the Required field. The only way is to implement a method returning the field for every validator type (or to use reflection and give up type safety).

Now, Go has an interesting feature, which is that you can embed other types in a struct, and you can even access the fields and methods of the embedded struct without naming it explicitly, so in principle I could do something like:

type BaseValidator struct {
    Required bool
}

type String struct {
    BaseValidator
    MaxLength int
}

and now if I instantiate a String struct s, I can even write s.Required without naming the embedded struct! This could solve my problem, except that when initializing the struct, I cannot write just String{Required: true}: I have to write String{BaseValidator: BaseValidator{Required: true}}, which ruins my pretty Map definition.

Another thing that could solve my problem is writing a constructor function for the String type, but since Go does not have keyword arguments, that does not look pretty either. The only solution that looks pretty in the client code is to repeat myself in the package code.

No love for unfinished programs

In Go, it is a compilation error to define a variable and not use it, or to import a module and not use it. I do think it’s worthwhile to ensure that these things are not present in finished code (the one that goes to code review and gets deployed); that’s why we have linters. But requiring it during development is a pain in the ass. Say you are debugging a piece of code. Comment out something to see what happens? Code does not compile because a variable is not in use. Or you add some debug prints, run the code, see what happens, comment out the debug print, run again… code does not compile because you import fmt and don’t use it. These seemingly minor but frequent annoyances break your flow during development.

There are lots of interesting invariants that are useful to ensure are respected in finished programs, but which will be violated at various points during development, between the time you check out the repository and the time you have a new version ready to be deployed. It is my long-standing position that running unfinished programs is a useful thing to be able to do; this is a topic I might revisit in a future blog post. It is okay when a language rejects an incomplete program for technical reasons (e.g., the implementation cannot ensure run-time safety for code that calls non-existent functions, or calls a function with the wrong argument types). What annoys me is when a language goes out of its way to stop you from running code that it would otherwise be perfectly capable of running. Java’s checked exceptions and Go’s unused variable/import checks fall into this category. This could be easily solved by having a compiler switch to disable those checks during development, but alas, no.

At the same time, a struct constructor with missing fields is not an error, not even a warning, so if you forget a field, or add a new field to the struct and forget to update some place that constructs it, you get no help from the language; not even golint will tell you about it. (Yes, there are useful use cases for omitting struct fields, but I would expect at least a linter option to detect this.)

One-letter identifiers are the norm

And this is enshrined in the Go Code Review Comments page from the Go wiki:

Variable names in Go should be short rather than long. This is especially true for local variables with limited scope. Prefer c to lineCount. Prefer i to sliceIndex.

Prefer c to lineCount? Why? It is general wisdom that code is read more often than it’s written, so it pays off to use descriptive variable names. It may be super clear for you, today, that c is a line count, but what about people new to the code base, or your future self 6 months from now? Is there any clarity gained by using c instead of lineCount? Is the code simpler?

As for i instead of sliceIndex… well, sure, since sliceIndex says very little about the slice’s purpose anyway. Depending on the context, there may be a better name than both i and sliceIndex to give to this variable. But I do grant that i may be an okay name for a slice index in a simple loop (although slice indexes don’t really appear that much anyway, since you can iterate over the values directly).

Testing

The only good thing I can say about Go’s testing infrastructure is that it exists; that’s about it. It is afflicted by Go’s obsession with single-letter identifiers (it defines types such as testing.T for tests, testing.B for benchmarks, testing.M for main test context). It provides no assert functions; you’re supposed to write an explicit if and panic to indicate test failures. (There is a popular library called Testify that provides asserts and also shows diffs between expected and found values.)

Despite doing very little, it also does too much. For instance, it caches test results by default (!). You can disable this behavior: “The idiomatic way to disable test caching explicitly”, I quote, “is to use -count=1.” (!!) It also runs tests from different packages in parallel by default, which makes for all sorts of fun if your tests use a database – the main one being spending a day figuring out why your tests don’t work, since this fact is not particularly prominent in documentation, i.e., it is not something you are likely to find out unless you are specifically looking for it. (You can disable parallelism, or use one of various third-party packages with different solutions to tests involving databases.)

The attitude

This one is very subjective, and not related to the language itself, but it just rubs me wrong when I see the Go designers speaking of Go as if it truly stood out from the crowd. Even when recognizing other languages, they seem to want to position Go as a pioneer in a great new wave of programming languages. From the Go FAQ:

We were not alone in our concerns. After many years with a pretty quiet landscape for programming languages, Go was among the first of several new languages—Rust, Elixir, Swift, and more—that have made programming language development an active, almost mainstream field again.

The Go project started by the end of 2007 and went public in 2009. Was the programming language landscape really that silent in the preceding years? Without doing any research other than checking the dates on Wikipedia, I can think of D (2001), Groovy (2003), Scala (2004), F# (2005), Vala (2006), Clojure (2007), and Nim (2008). So no, we were not in any kind of programming language dark ages before Go came along inaugurating a great renaissance of programming languages.

Recently I watched a video in which Rob Pike speaks of the fact that Go numeric constants work like abstract numbers without a specific type, so you can use 1 in a place expecting an int or a byte or a float64 without relying on type conversion rules, as a “relatively novel idea”. Guys, Haskell has had this at least since 1990. These ideas are not new, you have just been oblivious to the rest of the world.

Of course, Go does bring its own share of new ideas, and new ways to combine existing ideas. It just annoys me when they see themselves as doing something truly exceptional and out of the ordinary.

So why do I keep using this language?

And yet, despite all of the above, I still think Go was a good choice for implementing the Fenius interpreter, and I still think it’s a good choice in a variety of situations. So I think it’s appropriate to finish this post with some counterpoints to the above. Why do I keep using Go, despite all of the above problems?

First of all, it gets the job done. It is often the case that practical considerations, often having more to do with a language’s runtime and environment than with the language itself, lead to the choice of a given language for a job. For example, PHP has a terrible language design, but it’s super easy to deploy, so it makes sense to choose PHP for some tasks in some circumstances, even though there are plenty of better languages available. As for Go, regardless of any of the problems mentioned before, it does give me a lightweight memory-safe garbage-collected runtime, native self-contained executables, and does not try to hide the operating system from me. These characteristics make Go a good choice for my particular use case. (I should also note that, despite the above comparison with PHP, a lot of thought has been put into Go’s design, even if I disagree with many of the design choices.)

Second, in almost every respect in which Go is bad, C is even worse. So if you come to Go with a perspective of “I want something like C but less annoying”, Go actually delivers. And I would rather program in Go than in C++, even though C++ does not have many of the problems mentioned above, because the problems it does have are even worse. When I think from this perspective, I’m actually glad Go exists in this world, because it means I have fewer reasons to write C or C++.2

In fact, when you realize that Go came about as a reaction to C++, the relentless obsession with (a certain kind of) simplicity makes a lot more sense. C++ is a behemoth of a language, and it gets bigger with every new standard. Go is not only a very simple language, it makes it hard to build complex abstractions on the top of it, almost like a safeguard against C++-ish complexity creeping in. One can argue the reaction was too exaggerated, but I can understand where they are coming from.

There is a final bit of ambivalent praise I want to give Go, related to the above. I think Go embodies the Unix philosophy in a way no other recently designed language that I know of does. This is not an unambiguously good thing, mind you; it brings to my mind the worse is better concept, an interesting view of Unix and C by someone from outside of that tradition (and an essay with a fascinating story in itself). But Go had key Unix people among its designers – Ken Thompson (the inventor of Unix himself) and Rob Pike (who worked on Plan 9) – and it shows. For good and for bad, Go is exactly the kind of language you would expect Unix people to come up with if they sat down to design a higher-level successor to C. And notwithstanding all my misgivings about the language, I can respect that.

_____

1 Recently I learned it is possible to set a finalizer on an object, but they are not deterministic or related to scoping. I do find it a bit surprising that Go has finalizers, though.

2 If I did not need garbage collection, Rust would be a good option for this project as well. But as I mentioned in the beginning, I do need a garbage collector because Fenius is garbage-collected. If I were to implement it in a non-garbage-collected language, I would have to write a garbage collector for Fenius myself, whereas with Go or other garbage-collected languages, I can get away with relying on the host language’s garbage collector. I think of Rust and Go as complementary rather than in opposition, but that’s maybe a topic for another post.

3 comentários / comments

My terrible nginx rules for cgit

2020-08-16 12:40 +0100. Tags: comp, unix, web, in-english

I’ve been using cgit for hosting my Git repositories since about the end of 2018. One minor thing that annoys me about cgit is that the landing page for repositories is not the about page (which shows the readme), but the summary page (which shows the last commits and repository activity). This is a useful default if you are visiting the page of a project you already know (so you can see what’s new), but not so much for someone casually browsing your repos.

There were (at least) two ways I could solve this:

I went with the second option.

Things are not so simple, however: even if I map my external URLs into cgit’s internal ones, cgit will still generate links pointing to its own version of the URLs. So the evil masterplan is to have two root locations:

In the rest of this post, I will go through the nginx rules I used. You can find them here if you would like to see them all at once.

The cgit FastCGI snippet

We will need four different rules for the /code location. All of them involve passing a translated URL to cgit, which involves setting up a bunch of FastCGI variables, most of which are identical in all rules. So let’s start by creating a file in /etc/nginx/snippets/fastcgi-cgit.conf which we can reuse in all rules:

fastcgi_pass unix:/run/fcgiwrap.socket;

fastcgi_param  QUERY_STRING       $query_string;
fastcgi_param  REQUEST_METHOD     $request_method;
fastcgi_param  CONTENT_TYPE       $content_type;
fastcgi_param  CONTENT_LENGTH     $content_length;

fastcgi_param  REQUEST_URI        $request_uri;
fastcgi_param  DOCUMENT_URI       $document_uri;
fastcgi_param  SERVER_PROTOCOL    $server_protocol;

fastcgi_param  GATEWAY_INTERFACE  CGI/1.1;
fastcgi_param  SERVER_SOFTWARE    nginx/$nginx_version;

fastcgi_param  REMOTE_ADDR        $remote_addr;
fastcgi_param  REMOTE_PORT        $remote_port;
fastcgi_param  SERVER_ADDR        $server_addr;
fastcgi_param  SERVER_PORT        $server_port;
fastcgi_param  SERVER_NAME        $server_name;

# Tell fcgiwrap about the binary we’d like to execute and cgit about
# the path we’d like to access.
fastcgi_param  SCRIPT_FILENAME    /usr/lib/cgit/cgit.cgi;
fastcgi_param  DOCUMENT_ROOT      /usr/lib/cgit;

These are standard CGI parameters; the only thing specific to cgit here is the SCRIPT_FILENAME and DOCUMENT_ROOT variables. Change those according to where you have cgit installed in your system.

The /code/ rules

Now come the interesting rules. These go in the server { ... } nginx section for your website (likely in /etc/nginx/sites-enabled/site-name, if you are using a Debian derivative). Let’s begin by the rule for the landing page: we want /code/<repo-name>/ to map to cgit’s /<repo-name>/about/:

    location ~ ^/code/([^/]+)/?$ {
        include snippets/fastcgi-cgit.conf;
        fastcgi_param  SCRIPT_NAME  /cgit;
        fastcgi_param  PATH_INFO    $1/about/;
    }

The location line matches any URL beginning with /code/, followed by one or more characters other than /, followed by an optional /. So it matches /code/foo/, but not /code/foo/bar (because foo/bar has a /, i.e., it is not “one or more characters other than /”). The foo part (i.e., the repo name) will be accessible as $1 inside the rule (because that part of the URL string is captured by the parentheses in the regex).

Inside the rule, we include the snippet we defined before, and then we set two variables: SCRIPT_NAME, which is the base URL cgit will use for its own links; and PATH_INFO, which tells cgit which page we want (i.e., the <repo-name>/about page). Note that the base URL we pass to cgit is not /code, but /cgit, so cgit will generate links to URLs like /cgit/<repo-name>/about/. This is important because later on we will define rules to redirect /cgit URLs to their corresponding /code URLs.

The second rule we want is to expose the summary page as /code/<repo-name>/summary/, which will map to cgit’s repo landing page:

    location ~ ^/code/([^/]+)/summary/$ {
        include snippets/fastcgi-cgit.conf;
        fastcgi_param  SCRIPT_NAME  /cgit;
        fastcgi_param  PATH_INFO    $1/;
    }

Again, the principle is the same: we match /code/foo/summary/, extract the foo part, and pass a modified URL to cgit. In this case, we just pass foo/ without the summary, since cgit’s repo landing page is the summary.

The third rule is a catch-all rule for all the other URLs that don’t require translation:

    location ~ ^/code/(.*) {
        include snippets/fastcgi-cgit.conf;
        fastcgi_param  SCRIPT_NAME  /cgit;
        fastcgi_param  PATH_INFO    $1;
    }

That is, /code/ followed by anything else not matched by the previous rules is passed as is (removing the /code/ part) to cgit.

The /cgit/ rules

Now we need to do the mapping in reverse: we want cgit’s links (e.g., /cgit/<repo-name>/about) to redirect to our external version of them (e.g., /code/<repo-name>/). These rules are straightforward: for each of the translation rules we created in the previous session, we add a corresponding redirect here.

    location ~ ^/cgit/([^/]+)/about/$ {
        return 302 /code/$1/;
    }

    location ~ ^/cgit/([^/]+)/?$ {
        return 302 /code/$1/summary/;
    }

    location ~ ^/cgit/(.*)$ {
        return 302 /code/$1$is_args$args;
    }

[Update (2020-11-05): The last rule must have a $is_args$args at the end, so that query parameters are passed on in the redirect.]

The cherry on the top of the kludge

This set of rules will already work if all you want is to expose cgit’s URLs in a different form. But there is one thing missing: if we go to the cgit initial page (the repository list), all the links to repositories will be of the form /cgit/<repo-name>/, which our rules will translate to /code/<repo-name>/summary/. But we don’t want that! We want the links in the repository list to lead to the repo about page (i.e., /code/<repo-name>/, not /cgit/<repo-name>/). So what do we do now?

The solution is to pass a different base URL to cgit just for the initial page. So we add a zeroth rule (it has to come before all other /code/ rules so it matches first):

    location ~ ^/code/$ {
        include snippets/fastcgi-cgit.conf;
        fastcgi_param  SCRIPT_NAME  /code;
        fastcgi_param  PATH_INFO    /;
    }

The difference between these and the other rules is that we pass SCRIPT_NAME with the value of /code instead of /cgit, so that in the initial page, the links are of the form /code/<repo-name>/ instead of /cgit/<repo-name>/, which means they will render cgit’s /<repo-name>/about/ page instead of /<repo-name>/.

Beautiful, huh?

Caveats

One thing you have to ensure with these rules is that every repo has an about page; cgit only generates about pages for repos with a README, so your links will break if your repo doesn’t have one. One solution for this is to create a default README which cgit will use if the repo does not have a README itself. For this, I have the following settings in my /etc/cgitrc:

# Use the repo readme if available.
readme=:README.md

# Default README file. Make sure to put this file in a folder of its own,
# because all files in the folder become accessible via cgit.
readme=/home/elmord/cgit-default-readme/README.md

EOF

That’s all I have for today, folks. If you have comments, feel free to, well, leave a comment.

4 comentários / comments

Type parameters and dynamic types: a crazy idea

2020-07-24 22:19 +0100. Tags: comp, prog, pldesign, fenius, in-english

A while ago I wrote a couple of posts about an idea I had about how to mix static and dynamic typing and the problems with that idea. I've recently thought about a crazy solution to this problem, probably too crazy to implement in practice, but I want to write it down before it flees my mind.

The problem

Just to recapitulate, the original idea was to have reified type variables in the code, so that a generic function like:

let foo[T](x: T) = ...

would actually receive T as a value, though one that would be passed automatically by default if not explicitly specified by the programmer, i.e., when calling foo(5), the compiler would have enough information to actually call foo[Int](5) under the hood without the programmer having to spell it out.

The problem is how to handle heterogeneous data structures, such as lists of arbitrary objects. For example, when deserializing a JSON object like [1, "foo", true] into a List[T], there is no value we can give for T that carries enough information to decode any element of the list.

The solution

The solution I had proposed in the previous post was to have a Dynamic type which encapsulates the type information and the value, so you would use a List[Dynamic] here. The problem is that every value of the list has to be wrapped in a Dynamic container, i.e., the list becomes [Dynamic(1), Dynamic("foo"), Dynamic(true)].

But there is a more unconventional possibility hanging around here. First, the problem here is typing a heterogeneous sequence of elements as a list. But there is another sequence type that lends itself nicely for this purpose: the tuple. So although [1, "foo", true] can't be given a type, (1, "foo", true) can be given the type Tuple[Int, Str, Bool]. The problem is that, even if the Tuple type parameters are variables, the quantity of elements is fixed statically, i.e., it doesn't work for typing an arbitrarily long list deserialized from JSON input, for instance. But what if I give this value the type Tuple[*Ts], where * is the splice operator (turns a list into multiple arguments), and Ts is, well, a list of types? This list can be given an actual type: List[Type]. So now we have these interdependent dynamic types floating around, and to know the type of the_tuple[i], the type stored at Ts[i] has to be consulted.

I'm not sure how this would work in practice, though, especially when constructing this list. Though maybe in a functional setting, it might work. Our deserialization function would look like (in pseudo-code):

let parse_list(input: Str): Tuple[*Ts] = {
    if input == "" {
        ()
        # Returns a Tuple[], and Ts is implicitly [].
    } elif let (value, rest) = parse_integer(input) {
        (value, *parse_list(rest))
        # If parse_list(rest) is of type Tuple[*Ts],
        # (value, *parse_list(rest)) is of type Tuple[Int, *Ts].
    } ...
}

For dictionaries, things might be more complicated; the dictionary type is typically along the lines of Dict[KeyType, ValueType], and we are back to the same problem we had with lists. But just as heterogeneous lists map to tuples, we could perhaps map heterogeneous dictionaries to… anonymous records! So instead of having a dictionary {"a": 1, "b": true} of type Dict[Str, T], we would instead have a record (a=1, b=true) of type Record[a: Int, b: Bool]. And just as a dynamic list maps to Tuple[*Ts], a dynamic dictionary maps to Record[**Ts], where Ts is a dictionary of type Dict[Str, Type], mapping each record key to a type.

Could this work? Probably. Would it be practical or efficient? I'm not so sure. Would it be better than the alternative of just having a dynamic container, or even specialized types for dynamic collections? Probably not. But it sure as hell is an interesting idea.

Comentários / Comments

Main menu

Recent posts

Recent comments

Tags

em-portugues (213) comp (145) prog (70) in-english (59) life (48) unix (36) pldesign (36) lang (32) random (28) about (28) mind (26) lisp (24) mundane (22) fenius (21) web (20) ramble (18) img (13) hel (12) rant (12) privacy (10) scheme (10) freedom (8) lash (7) music (7) bash (7) academia (7) copyright (7) esperanto (7) shell (6) home (6) mestrado (6) misc (5) android (5) emacs (5) conlang (5) politics (4) worldly (4) book (4) latex (4) editor (4) etymology (4) php (4) tour-de-scheme (3) c (3) kbd (3) network (3) security (3) film (3) wrong (3) poem (2) cook (2) wm (2) treta (2) philosophy (2) llvm (2) lows (2) physics (2) comic (2) x11 (1) perl (1) golang (1) audio (1) old-chinese (1) translation (1) ai (1) kindle (1) german (1) pointless (1) en-esperanto (1)

Elsewhere

Quod vide


Copyright © 2010-2023 Vítor De Araújo
O conteúdo deste blog, a menos que de outra forma especificado, pode ser utilizado segundo os termos da licença Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International.

Powered by Blognir.